quizcomp.model.base

  1import copy
  2import logging
  3import math
  4import mimetypes
  5import os
  6import re
  7import typing
  8import urllib.parse
  9
 10import edq.net.request
 11import edq.util.json
 12import edq.util.serial
 13
 14import quizcomp.model.config
 15import quizcomp.model.constants
 16import quizcomp.model.errors
 17import quizcomp.parser.document
 18
 19_logger = logging.getLogger(__name__)
 20
 21DEFAULT_AVAILABLE_POINTS: int = 0
 22""" The default available points for an object. """
 23
 24class CoreType(edq.util.serial.DictConverter):
 25    """
 26    The base class for concepts that are considered "core types" to the quiz composer.
 27    This includes things like quizzes, variants, groups, and questions.
 28    Core types are generally serializable and should be aware of their base dir (for path resolution).
 29    """
 30
 31    serialization_omit_none = True
 32    serialization_omit_empty = True
 33    serialization_error_class = quizcomp.model.errors.QuizValidationError
 34    serialization_skip_fields = {
 35        'base_dir',
 36        'parent',
 37        'source_path',
 38    }
 39
 40    def __init__(self,
 41            base_dir: typing.Union[str, None] = None,
 42            source_path: typing.Union[str, None] = None,
 43            name: typing.Union[str, None] = None,
 44            parent: typing.Union['CoreType', None] = None,
 45            children: typing.Union[typing.Sequence['CoreType'], None] = None,
 46            points: typing.Union[float, int, None] = None,
 47            lms_id: typing.Union[str, None] = None,
 48            attributes: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 49            attributes_first: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 50            attributes_last: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 51            hints: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 52            hints_first: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 53            hints_last: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 54            style: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 55            style_first: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 56            style_last: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 57            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 58            **kwargs: typing.Any) -> None:
 59        if (base_dir is None):
 60            if (context is not None):
 61                base_dir = context.base_dir
 62            else:
 63                base_dir = '.'
 64
 65        self.base_dir: str = os.path.abspath(base_dir)
 66        """ The base directory for any relative paths this object needs to resolve. """
 67
 68        if ((source_path is None) and (context is not None)):
 69            source_path = context.source_path
 70
 71        if ((source_path is not None) and (not os.path.isabs(source_path))):
 72            source_path = os.path.abspath(os.path.join(self.base_dir, source_path))
 73
 74        self.source_path: typing.Union[str, None] = source_path
 75        """ If we are reading from a file, this attribute should be the absolute path to that file. """
 76
 77        if ((name is not None) and (len(name) == 0)):
 78            name = None
 79
 80        self.name: typing.Union[str, None] = name
 81        """
 82        The name of this object.
 83        If not specified, general names or names derived from parents will be used.
 84        """
 85
 86        self.parent: typing.Union['CoreType', None] = parent
 87        """
 88        The parent/container fr this object.
 89        The general pattern is: Quiz -> Variant -> Group -> Question.
 90        """
 91
 92        if (children is None):
 93            children = []
 94
 95        self.children: typing.List['CoreType'] = list(children)
 96        """
 97        Children of this object.
 98        The general pattern is: Quiz -> Variant -> Group -> Question.
 99        """
100
101        # Set the parent of the childen to self.
102        for child in self.children:
103            child.parent = self
104
105        if ((points is not None) and (points < 0)):
106            raise quizcomp.model.errors.QuizValidationError(f"Points must be either null/None or non-negative, found: {points}.", context = self)
107
108        if (points is not None):
109            points = float(points)
110            if (math.isclose(points, int(points))):
111                points = int(points)
112
113        self.points: typing.Union[float, int, None] = points
114        """
115        The number of points associated with this object.
116        This means different things depending on the context, e.g.,
117        for a question it is the number of available points,
118        and for a group it is the number of points for each question in the group.
119        """
120
121        self.lms_id: typing.Union[str, None] = lms_id
122        """ An ID to tie this object to an LMS (e.g. Moodle or Canvas). """
123
124        if (attributes is None):
125            attributes = {}
126
127        attributes.update(kwargs)
128
129        self.attributes: typing.Dict[str, edq.util.serial.PODType] = attributes.copy()
130        """
131        General attributes for this object.
132        Attributes are well-defined configurations for objects.
133        Attributes will always be observed (as long as the current version supports them).
134        """
135
136        if (attributes_first is None):
137            attributes_first = {}
138
139        self.attributes_first: typing.Dict[str, edq.util.serial.PODType] = attributes_first.copy()
140        """ Attributes to pass along to the first child of this object. """
141
142        if (attributes_last is None):
143            attributes_last = {}
144
145        self.attributes_last: typing.Dict[str, edq.util.serial.PODType] = attributes_last.copy()
146        """ Attributes to pass along to the last child of this object. """
147
148        if (hints is None):
149            hints = {}
150
151        self.hints: typing.Dict[str, edq.util.serial.PODType] = hints.copy()
152        """
153        Hints for this objects.
154        Hints generally affect layout for specific templates.
155        Hints may be ignored.
156        """
157
158        if (hints_first is None):
159            hints_first = {}
160
161        self.hints_first: typing.Dict[str, edq.util.serial.PODType] = hints_first.copy()
162        """ Hints to pass along to the first child of this object. """
163
164        if (hints_last is None):
165            hints_last = {}
166
167        self.hints_last: typing.Dict[str, edq.util.serial.PODType] = hints_last.copy()
168        """ Hints to pass along to the last child of this object. """
169
170        if (style is None):
171            style = {}
172
173        self.style: typing.Dict[str, edq.util.serial.PODType] = style.copy()
174        """
175        Styling rules for this object.
176        Style may be defined in text or in JSON.
177        """
178
179        if (style_first is None):
180            style_first = {}
181
182        self.style_first: typing.Dict[str, edq.util.serial.PODType] = style_first.copy()
183        """ Styles to pass along to the first child of this object. """
184
185        if (style_last is None):
186            style_last = {}
187
188        self.style_last: typing.Dict[str, edq.util.serial.PODType] = style_last.copy()
189        """ Styles to pass along to the last child of this object. """
190
191    def child_count(self) -> int:
192        """ Get the number of children for this object. """
193
194        return len(self.children)
195
196    def get_name(self,
197            default: str = '',
198            check_parent: bool = True,
199            shorten_only_children: bool = True,
200            ) -> str:
201        """
202        Get the name of this object.
203        If no name is set and `check_parent` is True, then a generic name based off of the child's position within the parent will be used;
204        otherwise, the default will be used.
205        """
206
207        if (self.name is not None):
208            return self.name
209
210        if (self.parent is None):
211            return default
212
213        parent_name = self.parent.get_name()
214        if (shorten_only_children and (len(self.parent.children) == 1)):
215            return parent_name
216
217        child_index = self.get_parent_index()
218        if (child_index is None):
219            return default
220
221        return f"{self.parent.get_name()} - {type(self).__name__} {quizcomp.model.constants.DEFAULT_CHOICES[child_index]}"
222
223    def get_parent_index(self) -> typing.Union[int, None]:
224        """ Get the index of this child within the parent's children (or None if there is no parent). """
225
226        if (self.parent is None):
227            return None
228
229        for (i, child) in enumerate(self.parent.children):
230            if (child is self):
231                return i
232
233        raise quizcomp.model.errors.QuizValidationError('Parent does not contain self as a child.', context = self)
234
235    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
236        """
237        Collect documents for this object only (does not include any children).
238        Use collect_all_documents() if you want child documents as well.
239        """
240
241        return []
242
243    def collect_all_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
244        """ Collect all documents for this object and all children. """
245
246        documents = self.collect_documents()
247        for child in self.children:
248            documents += child.collect_all_documents()
249
250        return documents
251
252    def get_points(self, check_children: bool = True) -> typing.Union[float, int]:
253        """
254        Get the total points available for this object.
255
256        This is computes as follows:
257        1) If a value (`points` field) is explicitly set, then return that.
258        2) If we have a parent, then ask them to compute points for is (via get_child_points()).
259        3) If we have children, then sum up their points.
260        4) Use DEFAULT_AVAILABLE_POINTS.
261        """
262
263        if (self.points is not None):
264            return self.points
265
266        if (self.parent is not None):
267            return self.parent.get_child_points()
268
269        if (check_children and (self.child_count() > 0)):
270            total = 0.0
271            for child in self.children:
272                total += child.get_points()
273
274            if (math.isclose(total, int(total))):
275                total = int(total)
276
277            return total
278
279        # Finally, return default.
280        return DEFAULT_AVAILABLE_POINTS
281
282    def get_child_points(self) -> typing.Union[float, int]:
283        """
284        Get the points available for a child of this object.
285        By default, this is the number of available points divided evenly amongst the children.
286        If no point configuration can be found, DEFAULT_AVAILABLE_POINTS should be returned.
287        """
288
289        split = 1.0
290        if (self.children is not None):
291            split = float(self.child_count())
292
293        # Make sure to not try to use the children to compute the available points.
294        value = self.get_points(check_children = False) / split
295        if (math.isclose(value, int(value))):
296            value = int(value)
297
298        return value
299
300    def get_display_points(self) -> str:
301        """
302        Return the output of get_points() rounded and formatted.
303        """
304
305        precision = int(self.get_config(quizcomp.model.config.OPTION_POINT_PRECISION))  # type: ignore[arg-type]
306
307        points = self.get_points()
308
309        if (precision <= 0):
310            return str(int(points))
311
312        points = round(points, precision)
313        return str(points)
314
315    def get_attribute(self, key: str, default_value: edq.util.serial.PODType) -> edq.util.serial.PODType:
316        """
317        Get an attribute value from this object or a parent.
318        If the key does not exist (or the value is None), return the given default.
319        """
320
321        value = self._get_hierarchical_value('attributes', key)
322        if (value is None):
323            return default_value
324
325        return value
326
327    def get_hint(self, key: str, default_value: edq.util.serial.PODType) -> edq.util.serial.PODType:
328        """
329        Get a hint value from this object or a parent.
330        If the key does not exist (or the value is None), return the given default.
331        """
332
333        value = self._get_hierarchical_value('hints', key)
334        if (value is None):
335            return default_value
336
337        return value
338
339    def get_style(self, key: str, default_value: edq.util.serial.PODType) -> edq.util.serial.PODType:
340        """
341        Get a style value from this object or a parent.
342        If the key does not exist (or the value is None), return the given default.
343        """
344
345        value = self._get_hierarchical_value('style', key)
346        if (value is None):
347            return default_value
348
349        return value
350
351    def get_config(self,
352            option: quizcomp.model.config.Option,
353            default_override: typing.Union[edq.util.serial.PODType, None] = None,
354            ) -> typing.Union[edq.util.serial.PODType, None]:
355        """
356        Get a value for a configuration option.
357        If the key does not exist (or the value is None), return the specified default.
358
359        Python callers should prefer this method, as mistakes can be caught by the type checker.
360        """
361
362        value = self._get_hierarchical_value(option.value_type, option.key)
363        if (value is None):
364            if (default_override is not None):
365                return default_override
366
367            return option.default_value
368
369        return value
370
371    def get_known_config(self,
372            key: str,
373            default_override: typing.Union[edq.util.serial.PODType, None] = None,
374            value_type: typing.Union[str, None] = None,
375            ) -> typing.Union[edq.util.serial.PODType, None]:
376        """
377        Get a value for a known configuration option by key.
378        If the key does not exist (or the value is None), return the specified default.
379
380        Non-Python callers will find this method useful.
381        """
382
383        option = quizcomp.model.config.get_known_option(key, value_type = value_type)
384        if (option is None):
385            return default_override
386
387        return self.get_config(option, default_override = default_override)
388
389    def _get_hierarchical_value(self, value_type: str, key: str,
390            child: typing.Union['CoreType', None] = None,
391            ) -> typing.Union[edq.util.serial.PODType, None]:
392        """
393        Get a value from either self or a parent (which may check its parent and so on).
394        Return None if no value is found.
395
396        If a value is passed for `child`, that value will be checked to see if it is the first or last child of this object,
397        which will then prompt for checking in the `_first` and `_last` containers.
398        `_first` and `_last` values will override base values.
399        Only children will favor `_last` if both are present.
400
401        The full order is: self, parent (first child), parent (last child), parent, gradnparent (first child), ...
402        """
403
404        if (not hasattr(self, value_type)):
405            raise ValueError(f"Unknown value type: '{value_type}'.")
406
407        container = getattr(self, value_type)
408
409        found = False
410        value = None
411
412        # Check for the value normally.
413        if (key in container):
414            found = True
415            value = container[key]
416
417        # Check if the given child is present.
418        if ((child is not None) and (child in self.children)):
419            child_index = self.children.index(child)
420
421            # Check if the child is a first.
422            if (child_index == 0):
423                context_container = getattr(self, value_type + '_first')
424                if ((context_container is not None) and (key in context_container)):
425                    found = True
426                    value = context_container[key]
427
428            # Check if the child is a last.
429            if (child_index == (self.child_count() - 1)):
430                context_container = getattr(self, value_type + '_last')
431                if ((context_container is not None) and (key in context_container)):
432                    found = True
433                    value = context_container[key]
434
435        if (found):
436            return value
437
438        # No value found here, and no parents.
439        if (self.parent is None):
440            return None
441
442        # No value found here, check the parent (using ourself as the context child).
443        return self.parent._get_hierarchical_value(value_type, key, child = self)
444
445    def to_path(self,
446            path: str,
447            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
448            ) -> None:
449        if (context is None):
450            context = edq.util.serial.SerializationContext()
451
452        context.json_options.setdefault('indent', 4)
453
454        super().to_path(path, context)
455
456    def to_dir(self,
457            base_dir: str,
458            fetch_images: bool = True,
459            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
460            **kwargs: typing.Any) -> None:
461        """
462        Write this object to the given directory.
463        This is different than to_path(), as that function just serializes the to a single JSON file,
464        whereas this method writes a directory in the Quiz Composer style.
465        """
466
467    def fetch_and_update_images(self, image_dirname: str = 'images') -> None:
468        """
469        Collect all the images for this object (not including children),
470        place them in the os.path.join(self.base_dir, image_dirname),
471        and update all documents with the new path.
472        """
473
474        # {old source: new source, ...}
475        new_sources = {}
476
477        out_dir = os.path.join(self.base_dir, image_dirname)
478
479        for document in self.collect_documents():
480            modified_tokens = False
481            for image_token in document.collect_images():
482                source = image_token.attrGet('src')
483                if ((source is None) or len(str(source)) == 0):
484                    _logger.warning("Could not locate image source for '%s'.", image_token.content)
485
486                source = str(source)
487                if (source not in new_sources):
488                    filename = self._fetch_image(source, out_dir, document.context.base_dir)
489                    new_sources[source] = f"{image_dirname}/{filename}"
490
491                image_token.attrSet('src', new_sources[source])
492
493            if (modified_tokens):
494                document.tokens_updated()
495
496    def _fetch_image(self, source: str, out_dir: str, base_dir: str) -> str:
497        """
498        Fetch an image and return its new filename.
499        """
500
501        edq.util.dirent.mkdir(out_dir)
502
503        is_http = re.match(r'^http(s)?://', source)
504        if (is_http):
505            url_path = urllib.parse.urlsplit(source).path
506            filename = url_path.split('/')[-1]
507        else:
508            filename = os.path.basename(source)
509
510        (basename, ext) = os.path.splitext(filename)
511        path = os.path.join(out_dir, filename)
512
513        count = 0
514        while (os.path.exists(path)):
515            filename = f"{basename}_{count:03d}{ext}"
516            path = os.path.join(out_dir, filename)
517            count += 1
518
519            if (count >= quizcomp.model.constants.MAX_IMAGE_RENAMES):
520                raise quizcomp.model.errors.QuizValidationError(f"Cannot create unique filename for image: '{source}'.", context = self)
521
522        if (is_http):
523            response, _ = edq.net.request.make_get(source)
524
525            # If the image source did not include an extension, try to parse one from the HTTP request.
526            if (len(ext) == 0):
527                new_ext = mimetypes.guess_extension(response.headers.get('content-type', None))  # type: ignore[arg-type]
528                if (new_ext is not None):
529                    filename += new_ext
530                    path = os.path.join(out_dir, filename)
531
532            edq.util.dirent.write_file_bytes(path, response.content)
533        else:
534            if (not os.path.isabs(source)):
535                source = os.path.join(base_dir, source)
536
537            source = os.path.abspath(source)
538            edq.util.dirent.copy(source, path)
539
540        return filename
541
542    def copy(self, context: typing.Union[edq.util.serial.SerializationContext, None] = None) -> 'CoreType':
543        return copy.deepcopy(self)
DEFAULT_AVAILABLE_POINTS: int = 0

The default available points for an object.

class CoreType(edq.util.serial.DictConverter):
 25class CoreType(edq.util.serial.DictConverter):
 26    """
 27    The base class for concepts that are considered "core types" to the quiz composer.
 28    This includes things like quizzes, variants, groups, and questions.
 29    Core types are generally serializable and should be aware of their base dir (for path resolution).
 30    """
 31
 32    serialization_omit_none = True
 33    serialization_omit_empty = True
 34    serialization_error_class = quizcomp.model.errors.QuizValidationError
 35    serialization_skip_fields = {
 36        'base_dir',
 37        'parent',
 38        'source_path',
 39    }
 40
 41    def __init__(self,
 42            base_dir: typing.Union[str, None] = None,
 43            source_path: typing.Union[str, None] = None,
 44            name: typing.Union[str, None] = None,
 45            parent: typing.Union['CoreType', None] = None,
 46            children: typing.Union[typing.Sequence['CoreType'], None] = None,
 47            points: typing.Union[float, int, None] = None,
 48            lms_id: typing.Union[str, None] = None,
 49            attributes: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 50            attributes_first: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 51            attributes_last: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 52            hints: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 53            hints_first: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 54            hints_last: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 55            style: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 56            style_first: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 57            style_last: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 58            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 59            **kwargs: typing.Any) -> None:
 60        if (base_dir is None):
 61            if (context is not None):
 62                base_dir = context.base_dir
 63            else:
 64                base_dir = '.'
 65
 66        self.base_dir: str = os.path.abspath(base_dir)
 67        """ The base directory for any relative paths this object needs to resolve. """
 68
 69        if ((source_path is None) and (context is not None)):
 70            source_path = context.source_path
 71
 72        if ((source_path is not None) and (not os.path.isabs(source_path))):
 73            source_path = os.path.abspath(os.path.join(self.base_dir, source_path))
 74
 75        self.source_path: typing.Union[str, None] = source_path
 76        """ If we are reading from a file, this attribute should be the absolute path to that file. """
 77
 78        if ((name is not None) and (len(name) == 0)):
 79            name = None
 80
 81        self.name: typing.Union[str, None] = name
 82        """
 83        The name of this object.
 84        If not specified, general names or names derived from parents will be used.
 85        """
 86
 87        self.parent: typing.Union['CoreType', None] = parent
 88        """
 89        The parent/container fr this object.
 90        The general pattern is: Quiz -> Variant -> Group -> Question.
 91        """
 92
 93        if (children is None):
 94            children = []
 95
 96        self.children: typing.List['CoreType'] = list(children)
 97        """
 98        Children of this object.
 99        The general pattern is: Quiz -> Variant -> Group -> Question.
100        """
101
102        # Set the parent of the childen to self.
103        for child in self.children:
104            child.parent = self
105
106        if ((points is not None) and (points < 0)):
107            raise quizcomp.model.errors.QuizValidationError(f"Points must be either null/None or non-negative, found: {points}.", context = self)
108
109        if (points is not None):
110            points = float(points)
111            if (math.isclose(points, int(points))):
112                points = int(points)
113
114        self.points: typing.Union[float, int, None] = points
115        """
116        The number of points associated with this object.
117        This means different things depending on the context, e.g.,
118        for a question it is the number of available points,
119        and for a group it is the number of points for each question in the group.
120        """
121
122        self.lms_id: typing.Union[str, None] = lms_id
123        """ An ID to tie this object to an LMS (e.g. Moodle or Canvas). """
124
125        if (attributes is None):
126            attributes = {}
127
128        attributes.update(kwargs)
129
130        self.attributes: typing.Dict[str, edq.util.serial.PODType] = attributes.copy()
131        """
132        General attributes for this object.
133        Attributes are well-defined configurations for objects.
134        Attributes will always be observed (as long as the current version supports them).
135        """
136
137        if (attributes_first is None):
138            attributes_first = {}
139
140        self.attributes_first: typing.Dict[str, edq.util.serial.PODType] = attributes_first.copy()
141        """ Attributes to pass along to the first child of this object. """
142
143        if (attributes_last is None):
144            attributes_last = {}
145
146        self.attributes_last: typing.Dict[str, edq.util.serial.PODType] = attributes_last.copy()
147        """ Attributes to pass along to the last child of this object. """
148
149        if (hints is None):
150            hints = {}
151
152        self.hints: typing.Dict[str, edq.util.serial.PODType] = hints.copy()
153        """
154        Hints for this objects.
155        Hints generally affect layout for specific templates.
156        Hints may be ignored.
157        """
158
159        if (hints_first is None):
160            hints_first = {}
161
162        self.hints_first: typing.Dict[str, edq.util.serial.PODType] = hints_first.copy()
163        """ Hints to pass along to the first child of this object. """
164
165        if (hints_last is None):
166            hints_last = {}
167
168        self.hints_last: typing.Dict[str, edq.util.serial.PODType] = hints_last.copy()
169        """ Hints to pass along to the last child of this object. """
170
171        if (style is None):
172            style = {}
173
174        self.style: typing.Dict[str, edq.util.serial.PODType] = style.copy()
175        """
176        Styling rules for this object.
177        Style may be defined in text or in JSON.
178        """
179
180        if (style_first is None):
181            style_first = {}
182
183        self.style_first: typing.Dict[str, edq.util.serial.PODType] = style_first.copy()
184        """ Styles to pass along to the first child of this object. """
185
186        if (style_last is None):
187            style_last = {}
188
189        self.style_last: typing.Dict[str, edq.util.serial.PODType] = style_last.copy()
190        """ Styles to pass along to the last child of this object. """
191
192    def child_count(self) -> int:
193        """ Get the number of children for this object. """
194
195        return len(self.children)
196
197    def get_name(self,
198            default: str = '',
199            check_parent: bool = True,
200            shorten_only_children: bool = True,
201            ) -> str:
202        """
203        Get the name of this object.
204        If no name is set and `check_parent` is True, then a generic name based off of the child's position within the parent will be used;
205        otherwise, the default will be used.
206        """
207
208        if (self.name is not None):
209            return self.name
210
211        if (self.parent is None):
212            return default
213
214        parent_name = self.parent.get_name()
215        if (shorten_only_children and (len(self.parent.children) == 1)):
216            return parent_name
217
218        child_index = self.get_parent_index()
219        if (child_index is None):
220            return default
221
222        return f"{self.parent.get_name()} - {type(self).__name__} {quizcomp.model.constants.DEFAULT_CHOICES[child_index]}"
223
224    def get_parent_index(self) -> typing.Union[int, None]:
225        """ Get the index of this child within the parent's children (or None if there is no parent). """
226
227        if (self.parent is None):
228            return None
229
230        for (i, child) in enumerate(self.parent.children):
231            if (child is self):
232                return i
233
234        raise quizcomp.model.errors.QuizValidationError('Parent does not contain self as a child.', context = self)
235
236    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
237        """
238        Collect documents for this object only (does not include any children).
239        Use collect_all_documents() if you want child documents as well.
240        """
241
242        return []
243
244    def collect_all_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
245        """ Collect all documents for this object and all children. """
246
247        documents = self.collect_documents()
248        for child in self.children:
249            documents += child.collect_all_documents()
250
251        return documents
252
253    def get_points(self, check_children: bool = True) -> typing.Union[float, int]:
254        """
255        Get the total points available for this object.
256
257        This is computes as follows:
258        1) If a value (`points` field) is explicitly set, then return that.
259        2) If we have a parent, then ask them to compute points for is (via get_child_points()).
260        3) If we have children, then sum up their points.
261        4) Use DEFAULT_AVAILABLE_POINTS.
262        """
263
264        if (self.points is not None):
265            return self.points
266
267        if (self.parent is not None):
268            return self.parent.get_child_points()
269
270        if (check_children and (self.child_count() > 0)):
271            total = 0.0
272            for child in self.children:
273                total += child.get_points()
274
275            if (math.isclose(total, int(total))):
276                total = int(total)
277
278            return total
279
280        # Finally, return default.
281        return DEFAULT_AVAILABLE_POINTS
282
283    def get_child_points(self) -> typing.Union[float, int]:
284        """
285        Get the points available for a child of this object.
286        By default, this is the number of available points divided evenly amongst the children.
287        If no point configuration can be found, DEFAULT_AVAILABLE_POINTS should be returned.
288        """
289
290        split = 1.0
291        if (self.children is not None):
292            split = float(self.child_count())
293
294        # Make sure to not try to use the children to compute the available points.
295        value = self.get_points(check_children = False) / split
296        if (math.isclose(value, int(value))):
297            value = int(value)
298
299        return value
300
301    def get_display_points(self) -> str:
302        """
303        Return the output of get_points() rounded and formatted.
304        """
305
306        precision = int(self.get_config(quizcomp.model.config.OPTION_POINT_PRECISION))  # type: ignore[arg-type]
307
308        points = self.get_points()
309
310        if (precision <= 0):
311            return str(int(points))
312
313        points = round(points, precision)
314        return str(points)
315
316    def get_attribute(self, key: str, default_value: edq.util.serial.PODType) -> edq.util.serial.PODType:
317        """
318        Get an attribute value from this object or a parent.
319        If the key does not exist (or the value is None), return the given default.
320        """
321
322        value = self._get_hierarchical_value('attributes', key)
323        if (value is None):
324            return default_value
325
326        return value
327
328    def get_hint(self, key: str, default_value: edq.util.serial.PODType) -> edq.util.serial.PODType:
329        """
330        Get a hint value from this object or a parent.
331        If the key does not exist (or the value is None), return the given default.
332        """
333
334        value = self._get_hierarchical_value('hints', key)
335        if (value is None):
336            return default_value
337
338        return value
339
340    def get_style(self, key: str, default_value: edq.util.serial.PODType) -> edq.util.serial.PODType:
341        """
342        Get a style value from this object or a parent.
343        If the key does not exist (or the value is None), return the given default.
344        """
345
346        value = self._get_hierarchical_value('style', key)
347        if (value is None):
348            return default_value
349
350        return value
351
352    def get_config(self,
353            option: quizcomp.model.config.Option,
354            default_override: typing.Union[edq.util.serial.PODType, None] = None,
355            ) -> typing.Union[edq.util.serial.PODType, None]:
356        """
357        Get a value for a configuration option.
358        If the key does not exist (or the value is None), return the specified default.
359
360        Python callers should prefer this method, as mistakes can be caught by the type checker.
361        """
362
363        value = self._get_hierarchical_value(option.value_type, option.key)
364        if (value is None):
365            if (default_override is not None):
366                return default_override
367
368            return option.default_value
369
370        return value
371
372    def get_known_config(self,
373            key: str,
374            default_override: typing.Union[edq.util.serial.PODType, None] = None,
375            value_type: typing.Union[str, None] = None,
376            ) -> typing.Union[edq.util.serial.PODType, None]:
377        """
378        Get a value for a known configuration option by key.
379        If the key does not exist (or the value is None), return the specified default.
380
381        Non-Python callers will find this method useful.
382        """
383
384        option = quizcomp.model.config.get_known_option(key, value_type = value_type)
385        if (option is None):
386            return default_override
387
388        return self.get_config(option, default_override = default_override)
389
390    def _get_hierarchical_value(self, value_type: str, key: str,
391            child: typing.Union['CoreType', None] = None,
392            ) -> typing.Union[edq.util.serial.PODType, None]:
393        """
394        Get a value from either self or a parent (which may check its parent and so on).
395        Return None if no value is found.
396
397        If a value is passed for `child`, that value will be checked to see if it is the first or last child of this object,
398        which will then prompt for checking in the `_first` and `_last` containers.
399        `_first` and `_last` values will override base values.
400        Only children will favor `_last` if both are present.
401
402        The full order is: self, parent (first child), parent (last child), parent, gradnparent (first child), ...
403        """
404
405        if (not hasattr(self, value_type)):
406            raise ValueError(f"Unknown value type: '{value_type}'.")
407
408        container = getattr(self, value_type)
409
410        found = False
411        value = None
412
413        # Check for the value normally.
414        if (key in container):
415            found = True
416            value = container[key]
417
418        # Check if the given child is present.
419        if ((child is not None) and (child in self.children)):
420            child_index = self.children.index(child)
421
422            # Check if the child is a first.
423            if (child_index == 0):
424                context_container = getattr(self, value_type + '_first')
425                if ((context_container is not None) and (key in context_container)):
426                    found = True
427                    value = context_container[key]
428
429            # Check if the child is a last.
430            if (child_index == (self.child_count() - 1)):
431                context_container = getattr(self, value_type + '_last')
432                if ((context_container is not None) and (key in context_container)):
433                    found = True
434                    value = context_container[key]
435
436        if (found):
437            return value
438
439        # No value found here, and no parents.
440        if (self.parent is None):
441            return None
442
443        # No value found here, check the parent (using ourself as the context child).
444        return self.parent._get_hierarchical_value(value_type, key, child = self)
445
446    def to_path(self,
447            path: str,
448            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
449            ) -> None:
450        if (context is None):
451            context = edq.util.serial.SerializationContext()
452
453        context.json_options.setdefault('indent', 4)
454
455        super().to_path(path, context)
456
457    def to_dir(self,
458            base_dir: str,
459            fetch_images: bool = True,
460            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
461            **kwargs: typing.Any) -> None:
462        """
463        Write this object to the given directory.
464        This is different than to_path(), as that function just serializes the to a single JSON file,
465        whereas this method writes a directory in the Quiz Composer style.
466        """
467
468    def fetch_and_update_images(self, image_dirname: str = 'images') -> None:
469        """
470        Collect all the images for this object (not including children),
471        place them in the os.path.join(self.base_dir, image_dirname),
472        and update all documents with the new path.
473        """
474
475        # {old source: new source, ...}
476        new_sources = {}
477
478        out_dir = os.path.join(self.base_dir, image_dirname)
479
480        for document in self.collect_documents():
481            modified_tokens = False
482            for image_token in document.collect_images():
483                source = image_token.attrGet('src')
484                if ((source is None) or len(str(source)) == 0):
485                    _logger.warning("Could not locate image source for '%s'.", image_token.content)
486
487                source = str(source)
488                if (source not in new_sources):
489                    filename = self._fetch_image(source, out_dir, document.context.base_dir)
490                    new_sources[source] = f"{image_dirname}/{filename}"
491
492                image_token.attrSet('src', new_sources[source])
493
494            if (modified_tokens):
495                document.tokens_updated()
496
497    def _fetch_image(self, source: str, out_dir: str, base_dir: str) -> str:
498        """
499        Fetch an image and return its new filename.
500        """
501
502        edq.util.dirent.mkdir(out_dir)
503
504        is_http = re.match(r'^http(s)?://', source)
505        if (is_http):
506            url_path = urllib.parse.urlsplit(source).path
507            filename = url_path.split('/')[-1]
508        else:
509            filename = os.path.basename(source)
510
511        (basename, ext) = os.path.splitext(filename)
512        path = os.path.join(out_dir, filename)
513
514        count = 0
515        while (os.path.exists(path)):
516            filename = f"{basename}_{count:03d}{ext}"
517            path = os.path.join(out_dir, filename)
518            count += 1
519
520            if (count >= quizcomp.model.constants.MAX_IMAGE_RENAMES):
521                raise quizcomp.model.errors.QuizValidationError(f"Cannot create unique filename for image: '{source}'.", context = self)
522
523        if (is_http):
524            response, _ = edq.net.request.make_get(source)
525
526            # If the image source did not include an extension, try to parse one from the HTTP request.
527            if (len(ext) == 0):
528                new_ext = mimetypes.guess_extension(response.headers.get('content-type', None))  # type: ignore[arg-type]
529                if (new_ext is not None):
530                    filename += new_ext
531                    path = os.path.join(out_dir, filename)
532
533            edq.util.dirent.write_file_bytes(path, response.content)
534        else:
535            if (not os.path.isabs(source)):
536                source = os.path.join(base_dir, source)
537
538            source = os.path.abspath(source)
539            edq.util.dirent.copy(source, path)
540
541        return filename
542
543    def copy(self, context: typing.Union[edq.util.serial.SerializationContext, None] = None) -> 'CoreType':
544        return copy.deepcopy(self)

The base class for concepts that are considered "core types" to the quiz composer. This includes things like quizzes, variants, groups, and questions. Core types are generally serializable and should be aware of their base dir (for path resolution).

CoreType( base_dir: Optional[str] = None, source_path: Optional[str] = None, name: Optional[str] = None, parent: Optional[CoreType] = None, children: Optional[Sequence[CoreType]] = None, points: Union[float, int, NoneType] = None, lms_id: Optional[str] = None, attributes: Optional[Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]] = None, attributes_first: Optional[Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]] = None, attributes_last: Optional[Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]] = None, hints: Optional[Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]] = None, hints_first: Optional[Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]] = None, hints_last: Optional[Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]] = None, style: Optional[Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]] = None, style_first: Optional[Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]] = None, style_last: Optional[Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]]] = None, context: Optional[edq.util.common.SerializationContext] = None, **kwargs: Any)
 41    def __init__(self,
 42            base_dir: typing.Union[str, None] = None,
 43            source_path: typing.Union[str, None] = None,
 44            name: typing.Union[str, None] = None,
 45            parent: typing.Union['CoreType', None] = None,
 46            children: typing.Union[typing.Sequence['CoreType'], None] = None,
 47            points: typing.Union[float, int, None] = None,
 48            lms_id: typing.Union[str, None] = None,
 49            attributes: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 50            attributes_first: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 51            attributes_last: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 52            hints: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 53            hints_first: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 54            hints_last: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 55            style: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 56            style_first: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 57            style_last: typing.Union[typing.Dict[str, edq.util.serial.PODType], None] = None,
 58            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
 59            **kwargs: typing.Any) -> None:
 60        if (base_dir is None):
 61            if (context is not None):
 62                base_dir = context.base_dir
 63            else:
 64                base_dir = '.'
 65
 66        self.base_dir: str = os.path.abspath(base_dir)
 67        """ The base directory for any relative paths this object needs to resolve. """
 68
 69        if ((source_path is None) and (context is not None)):
 70            source_path = context.source_path
 71
 72        if ((source_path is not None) and (not os.path.isabs(source_path))):
 73            source_path = os.path.abspath(os.path.join(self.base_dir, source_path))
 74
 75        self.source_path: typing.Union[str, None] = source_path
 76        """ If we are reading from a file, this attribute should be the absolute path to that file. """
 77
 78        if ((name is not None) and (len(name) == 0)):
 79            name = None
 80
 81        self.name: typing.Union[str, None] = name
 82        """
 83        The name of this object.
 84        If not specified, general names or names derived from parents will be used.
 85        """
 86
 87        self.parent: typing.Union['CoreType', None] = parent
 88        """
 89        The parent/container fr this object.
 90        The general pattern is: Quiz -> Variant -> Group -> Question.
 91        """
 92
 93        if (children is None):
 94            children = []
 95
 96        self.children: typing.List['CoreType'] = list(children)
 97        """
 98        Children of this object.
 99        The general pattern is: Quiz -> Variant -> Group -> Question.
100        """
101
102        # Set the parent of the childen to self.
103        for child in self.children:
104            child.parent = self
105
106        if ((points is not None) and (points < 0)):
107            raise quizcomp.model.errors.QuizValidationError(f"Points must be either null/None or non-negative, found: {points}.", context = self)
108
109        if (points is not None):
110            points = float(points)
111            if (math.isclose(points, int(points))):
112                points = int(points)
113
114        self.points: typing.Union[float, int, None] = points
115        """
116        The number of points associated with this object.
117        This means different things depending on the context, e.g.,
118        for a question it is the number of available points,
119        and for a group it is the number of points for each question in the group.
120        """
121
122        self.lms_id: typing.Union[str, None] = lms_id
123        """ An ID to tie this object to an LMS (e.g. Moodle or Canvas). """
124
125        if (attributes is None):
126            attributes = {}
127
128        attributes.update(kwargs)
129
130        self.attributes: typing.Dict[str, edq.util.serial.PODType] = attributes.copy()
131        """
132        General attributes for this object.
133        Attributes are well-defined configurations for objects.
134        Attributes will always be observed (as long as the current version supports them).
135        """
136
137        if (attributes_first is None):
138            attributes_first = {}
139
140        self.attributes_first: typing.Dict[str, edq.util.serial.PODType] = attributes_first.copy()
141        """ Attributes to pass along to the first child of this object. """
142
143        if (attributes_last is None):
144            attributes_last = {}
145
146        self.attributes_last: typing.Dict[str, edq.util.serial.PODType] = attributes_last.copy()
147        """ Attributes to pass along to the last child of this object. """
148
149        if (hints is None):
150            hints = {}
151
152        self.hints: typing.Dict[str, edq.util.serial.PODType] = hints.copy()
153        """
154        Hints for this objects.
155        Hints generally affect layout for specific templates.
156        Hints may be ignored.
157        """
158
159        if (hints_first is None):
160            hints_first = {}
161
162        self.hints_first: typing.Dict[str, edq.util.serial.PODType] = hints_first.copy()
163        """ Hints to pass along to the first child of this object. """
164
165        if (hints_last is None):
166            hints_last = {}
167
168        self.hints_last: typing.Dict[str, edq.util.serial.PODType] = hints_last.copy()
169        """ Hints to pass along to the last child of this object. """
170
171        if (style is None):
172            style = {}
173
174        self.style: typing.Dict[str, edq.util.serial.PODType] = style.copy()
175        """
176        Styling rules for this object.
177        Style may be defined in text or in JSON.
178        """
179
180        if (style_first is None):
181            style_first = {}
182
183        self.style_first: typing.Dict[str, edq.util.serial.PODType] = style_first.copy()
184        """ Styles to pass along to the first child of this object. """
185
186        if (style_last is None):
187            style_last = {}
188
189        self.style_last: typing.Dict[str, edq.util.serial.PODType] = style_last.copy()
190        """ Styles to pass along to the last child of this object. """
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.
serialization_error_class = <class 'quizcomp.model.errors.QuizValidationError'>

The class to use when raising errors.

serialization_skip_fields = {'base_dir', 'source_path', 'parent'}

A list of field names to skip.

base_dir: str

The base directory for any relative paths this object needs to resolve.

source_path: Optional[str]

If we are reading from a file, this attribute should be the absolute path to that file.

name: Optional[str]

The name of this object. If not specified, general names or names derived from parents will be used.

parent: Optional[CoreType]

The parent/container fr this object. The general pattern is: Quiz -> Variant -> Group -> Question.

children: List[CoreType]

Children of this object. The general pattern is: Quiz -> Variant -> Group -> Question.

points: Union[float, int, NoneType]

The number of points associated with this object. This means different things depending on the context, e.g., for a question it is the number of available points, and for a group it is the number of points for each question in the group.

lms_id: Optional[str]

An ID to tie this object to an LMS (e.g. Moodle or Canvas).

attributes: 'typing.Dict[str, edq.util.serial.PODType]'

General attributes for this object. Attributes are well-defined configurations for objects. Attributes will always be observed (as long as the current version supports them).

attributes_first: 'typing.Dict[str, edq.util.serial.PODType]'

Attributes to pass along to the first child of this object.

attributes_last: 'typing.Dict[str, edq.util.serial.PODType]'

Attributes to pass along to the last child of this object.

hints: 'typing.Dict[str, edq.util.serial.PODType]'

Hints for this objects. Hints generally affect layout for specific templates. Hints may be ignored.

hints_first: 'typing.Dict[str, edq.util.serial.PODType]'

Hints to pass along to the first child of this object.

hints_last: 'typing.Dict[str, edq.util.serial.PODType]'

Hints to pass along to the last child of this object.

style: 'typing.Dict[str, edq.util.serial.PODType]'

Styling rules for this object. Style may be defined in text or in JSON.

style_first: 'typing.Dict[str, edq.util.serial.PODType]'

Styles to pass along to the first child of this object.

style_last: 'typing.Dict[str, edq.util.serial.PODType]'

Styles to pass along to the last child of this object.

def child_count(self) -> int:
192    def child_count(self) -> int:
193        """ Get the number of children for this object. """
194
195        return len(self.children)

Get the number of children for this object.

def get_name( self, default: str = '', check_parent: bool = True, shorten_only_children: bool = True) -> str:
197    def get_name(self,
198            default: str = '',
199            check_parent: bool = True,
200            shorten_only_children: bool = True,
201            ) -> str:
202        """
203        Get the name of this object.
204        If no name is set and `check_parent` is True, then a generic name based off of the child's position within the parent will be used;
205        otherwise, the default will be used.
206        """
207
208        if (self.name is not None):
209            return self.name
210
211        if (self.parent is None):
212            return default
213
214        parent_name = self.parent.get_name()
215        if (shorten_only_children and (len(self.parent.children) == 1)):
216            return parent_name
217
218        child_index = self.get_parent_index()
219        if (child_index is None):
220            return default
221
222        return f"{self.parent.get_name()} - {type(self).__name__} {quizcomp.model.constants.DEFAULT_CHOICES[child_index]}"

Get the name of this object. If no name is set and check_parent is True, then a generic name based off of the child's position within the parent will be used; otherwise, the default will be used.

def get_parent_index(self) -> Optional[int]:
224    def get_parent_index(self) -> typing.Union[int, None]:
225        """ Get the index of this child within the parent's children (or None if there is no parent). """
226
227        if (self.parent is None):
228            return None
229
230        for (i, child) in enumerate(self.parent.children):
231            if (child is self):
232                return i
233
234        raise quizcomp.model.errors.QuizValidationError('Parent does not contain self as a child.', context = self)

Get the index of this child within the parent's children (or None if there is no parent).

def collect_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
236    def collect_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
237        """
238        Collect documents for this object only (does not include any children).
239        Use collect_all_documents() if you want child documents as well.
240        """
241
242        return []

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

def collect_all_documents(self) -> List[quizcomp.parser.document.ParsedDocument]:
244    def collect_all_documents(self) -> typing.List[quizcomp.parser.document.ParsedDocument]:
245        """ Collect all documents for this object and all children. """
246
247        documents = self.collect_documents()
248        for child in self.children:
249            documents += child.collect_all_documents()
250
251        return documents

Collect all documents for this object and all children.

def get_points(self, check_children: bool = True) -> Union[float, int]:
253    def get_points(self, check_children: bool = True) -> typing.Union[float, int]:
254        """
255        Get the total points available for this object.
256
257        This is computes as follows:
258        1) If a value (`points` field) is explicitly set, then return that.
259        2) If we have a parent, then ask them to compute points for is (via get_child_points()).
260        3) If we have children, then sum up their points.
261        4) Use DEFAULT_AVAILABLE_POINTS.
262        """
263
264        if (self.points is not None):
265            return self.points
266
267        if (self.parent is not None):
268            return self.parent.get_child_points()
269
270        if (check_children and (self.child_count() > 0)):
271            total = 0.0
272            for child in self.children:
273                total += child.get_points()
274
275            if (math.isclose(total, int(total))):
276                total = int(total)
277
278            return total
279
280        # Finally, return default.
281        return DEFAULT_AVAILABLE_POINTS

Get the total points available for this object.

This is computes as follows: 1) If a value (points field) is explicitly set, then return that. 2) If we have a parent, then ask them to compute points for is (via get_child_points()). 3) If we have children, then sum up their points. 4) Use DEFAULT_AVAILABLE_POINTS.

def get_child_points(self) -> Union[float, int]:
283    def get_child_points(self) -> typing.Union[float, int]:
284        """
285        Get the points available for a child of this object.
286        By default, this is the number of available points divided evenly amongst the children.
287        If no point configuration can be found, DEFAULT_AVAILABLE_POINTS should be returned.
288        """
289
290        split = 1.0
291        if (self.children is not None):
292            split = float(self.child_count())
293
294        # Make sure to not try to use the children to compute the available points.
295        value = self.get_points(check_children = False) / split
296        if (math.isclose(value, int(value))):
297            value = int(value)
298
299        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 get_display_points(self) -> str:
301    def get_display_points(self) -> str:
302        """
303        Return the output of get_points() rounded and formatted.
304        """
305
306        precision = int(self.get_config(quizcomp.model.config.OPTION_POINT_PRECISION))  # type: ignore[arg-type]
307
308        points = self.get_points()
309
310        if (precision <= 0):
311            return str(int(points))
312
313        points = round(points, precision)
314        return str(points)

Return the output of get_points() rounded and formatted.

def get_attribute( self, key: str, default_value: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
316    def get_attribute(self, key: str, default_value: edq.util.serial.PODType) -> edq.util.serial.PODType:
317        """
318        Get an attribute value from this object or a parent.
319        If the key does not exist (or the value is None), return the given default.
320        """
321
322        value = self._get_hierarchical_value('attributes', key)
323        if (value is None):
324            return default_value
325
326        return value

Get an attribute value from this object or a parent. If the key does not exist (or the value is None), return the given default.

def get_hint( self, key: str, default_value: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
328    def get_hint(self, key: str, default_value: edq.util.serial.PODType) -> edq.util.serial.PODType:
329        """
330        Get a hint value from this object or a parent.
331        If the key does not exist (or the value is None), return the given default.
332        """
333
334        value = self._get_hierarchical_value('hints', key)
335        if (value is None):
336            return default_value
337
338        return value

Get a hint value from this object or a parent. If the key does not exist (or the value is None), return the given default.

def get_style( self, key: str, default_value: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
340    def get_style(self, key: str, default_value: edq.util.serial.PODType) -> edq.util.serial.PODType:
341        """
342        Get a style value from this object or a parent.
343        If the key does not exist (or the value is None), return the given default.
344        """
345
346        value = self._get_hierarchical_value('style', key)
347        if (value is None):
348            return default_value
349
350        return value

Get a style value from this object or a parent. If the key does not exist (or the value is None), return the given default.

def get_config( self, option: quizcomp.model.config.Option, default_override: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType] = None) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
352    def get_config(self,
353            option: quizcomp.model.config.Option,
354            default_override: typing.Union[edq.util.serial.PODType, None] = None,
355            ) -> typing.Union[edq.util.serial.PODType, None]:
356        """
357        Get a value for a configuration option.
358        If the key does not exist (or the value is None), return the specified default.
359
360        Python callers should prefer this method, as mistakes can be caught by the type checker.
361        """
362
363        value = self._get_hierarchical_value(option.value_type, option.key)
364        if (value is None):
365            if (default_override is not None):
366                return default_override
367
368            return option.default_value
369
370        return value

Get a value for a configuration option. If the key does not exist (or the value is None), return the specified default.

Python callers should prefer this method, as mistakes can be caught by the type checker.

def get_known_config( self, key: str, default_override: Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType] = None, value_type: Optional[str] = None) -> Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]:
372    def get_known_config(self,
373            key: str,
374            default_override: typing.Union[edq.util.serial.PODType, None] = None,
375            value_type: typing.Union[str, None] = None,
376            ) -> typing.Union[edq.util.serial.PODType, None]:
377        """
378        Get a value for a known configuration option by key.
379        If the key does not exist (or the value is None), return the specified default.
380
381        Non-Python callers will find this method useful.
382        """
383
384        option = quizcomp.model.config.get_known_option(key, value_type = value_type)
385        if (option is None):
386            return default_override
387
388        return self.get_config(option, default_override = default_override)

Get a value for a known configuration option by key. If the key does not exist (or the value is None), return the specified default.

Non-Python callers will find this method useful.

def to_path( self, path: str, context: Optional[edq.util.common.SerializationContext] = None) -> None:
446    def to_path(self,
447            path: str,
448            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
449            ) -> None:
450        if (context is None):
451            context = edq.util.serial.SerializationContext()
452
453        context.json_options.setdefault('indent', 4)
454
455        super().to_path(path, context)

Write this object to the given path as JSON.

def to_dir( self, base_dir: str, fetch_images: bool = True, context: Optional[edq.util.common.SerializationContext] = None, **kwargs: Any) -> None:
457    def to_dir(self,
458            base_dir: str,
459            fetch_images: bool = True,
460            context: typing.Union[edq.util.serial.SerializationContext, None] = None,
461            **kwargs: typing.Any) -> None:
462        """
463        Write this object to the given directory.
464        This is different than to_path(), as that function just serializes the to a single JSON file,
465        whereas this method writes a directory in the Quiz Composer style.
466        """

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.

def fetch_and_update_images(self, image_dirname: str = 'images') -> None:
468    def fetch_and_update_images(self, image_dirname: str = 'images') -> None:
469        """
470        Collect all the images for this object (not including children),
471        place them in the os.path.join(self.base_dir, image_dirname),
472        and update all documents with the new path.
473        """
474
475        # {old source: new source, ...}
476        new_sources = {}
477
478        out_dir = os.path.join(self.base_dir, image_dirname)
479
480        for document in self.collect_documents():
481            modified_tokens = False
482            for image_token in document.collect_images():
483                source = image_token.attrGet('src')
484                if ((source is None) or len(str(source)) == 0):
485                    _logger.warning("Could not locate image source for '%s'.", image_token.content)
486
487                source = str(source)
488                if (source not in new_sources):
489                    filename = self._fetch_image(source, out_dir, document.context.base_dir)
490                    new_sources[source] = f"{image_dirname}/{filename}"
491
492                image_token.attrSet('src', new_sources[source])
493
494            if (modified_tokens):
495                document.tokens_updated()

Collect all the images for this object (not including children), place them in the os.path.join(self.base_dir, image_dirname), and update all documents with the new path.

def copy( self, context: Optional[edq.util.common.SerializationContext] = None) -> CoreType:
543    def copy(self, context: typing.Union[edq.util.serial.SerializationContext, None] = None) -> 'CoreType':
544        return copy.deepcopy(self)

Make a deep copy of this object. The default implementation will use to_pod() and from_pod() to make a copy.

Callers should be cautious of fileds that are skipped in serialization, e.g., via SerializationBase.serialization_skip_fields.