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)
The default available points for an object.
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).
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. """
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_emptymethod that returns true.
The class to use when raising errors.
If we are reading from a file, this attribute should be the absolute path to that file.
The name of this object. If not specified, general names or names derived from parents will be used.
The parent/container fr this object. The general pattern is: Quiz -> Variant -> Group -> Question.
Children of this object. The general pattern is: Quiz -> Variant -> Group -> Question.
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.
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 to pass along to the first child of this object.
Attributes to pass along to the last child of this object.
Hints for this objects. Hints generally affect layout for specific templates. Hints may be ignored.
Hints to pass along to the first child of this object.
Hints to pass along to the last child of this object.
Styling rules for this object. Style may be defined in text or in JSON.
Styles to pass along to the first child of this object.
Styles to pass along to the last child of this object.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.