result_info.py revision 132c7a9b1075acf9c120a84cd24ea12ef05a9e33
1# Copyright 2017 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5"""Wrapper class to store size related information of test results. 6""" 7 8import copy 9import json 10import os 11 12import result_info_lib 13import utils_lib 14 15 16class ResultInfoError(Exception): 17 """Exception to raise when error occurs in ResultInfo collection.""" 18 19 20class ResultInfo(dict): 21 """A wrapper class to store result file information. 22 23 Details of a result include: 24 original_size: Original size in bytes of the result, before throttling. 25 trimmed_size: Size in bytes after the result is throttled. 26 collected_size: Size in bytes of the results collected from the dut. 27 files: A list of ResultInfo for the files and sub-directories of the result. 28 29 The class contains the size information of a result file/directory, and the 30 information can be merged if a file was collected multiple times during 31 the test. 32 For example, `messages` of size 100 bytes was collected before the test 33 starts, ResultInfo for this file shall be: 34 {'messages': {'/S': 100}} 35 Later in the test, the file was collected again when it's size becomes 200 36 bytes, the new ResultInfo will be: 37 {'messages': {'/S': 200}} 38 39 Not that the result infos collected from the dut don't have collected_size 40 (/C) set. That's because the collected size in such case is equal to the 41 trimmed_size (/T). If the reuslt is not trimmed and /T is not set, the 42 value of collected_size can fall back to original_size. The design is to not 43 to inject duplicated information in the summary json file, thus reduce the 44 size of data needs to be transfered from the dut. 45 46 At the end of the test, the file is considered too big, and trimmed down to 47 150 bytes, thus the final ResultInfo of the file becomes: 48 {'messages': {# The original size is 200 bytes 49 '/S': 200, 50 # The total collected size is 300(100+200} bytes 51 '/C': 300, 52 # The trimmed size is the final size on disk 53 '/T': 150} 54 From this example, the original size tells us how large the file was. 55 The collected size tells us how much data was transfered from dut to drone 56 to get this file. And the trimmed size shows the final size of the file when 57 the test is finished and the results are throttled again on the server side. 58 59 The class is a wrapper of dictionary. The properties are all keyvals in a 60 dictionary. For example, an instance of ResultInfo can have following 61 dictionary value: 62 {'debug': { 63 # Original size of the debug folder is 1000 bytes. 64 '/S': 1000, 65 # The debug folder was throttled and the size is reduced to 500 66 # bytes. 67 '/T': 500, 68 # collected_size ('/C') can be ignored, its value falls back to 69 # trimmed_size ('/T'). If trimmed_size is not set, its value falls 70 # back to original_size ('S') 71 72 # Sub-files and sub-directories are included in a list of '/D''s 73 # value. 74 # In this example, debug folder has a file `file1`, whose original 75 # size is 1000 bytes, which is trimmed down to 500 bytes. 76 '/D': [ 77 {'file1': { 78 '/S': 1000, 79 '/T': 500, 80 } 81 } 82 ] 83 } 84 } 85 """ 86 87 def __init__(self, parent_dir, name=None, parent_result_info=None, 88 original_info=None): 89 """Initialize a collection of size information for a given result path. 90 91 A ResultInfo object can be initialized in two ways: 92 1. Create from a physical file, which reads the size from the file. 93 In this case, `name` value should be given, and `original_info` shoud 94 not be set. 95 2. Create from previously collected information, i.e., a dictionary 96 deserialized from persisted json file. In this case, `original_info` 97 should be given, and `name` should not be set. 98 99 @param parent_dir: Path to the parent directory. 100 @param name: Name of the result file or directory. 101 @param parent_result_info: A ResultInfo object for the parent directory. 102 @param original_info: A dictionary of the result's size information. 103 This is retrieved from the previously serialized json string. 104 For example: {'file_name': 105 {'/S': 100, '/T': 50} 106 } 107 which means a file's original size is 100 bytes, and trimmed 108 down to 50 bytes. This argument is used when the object is 109 restored from a json string. 110 """ 111 super(ResultInfo, self).__init__() 112 113 if name is not None and original_info is not None: 114 raise ResultInfoError( 115 'Only one of parameter `name` and `original_info` can be ' 116 'set.') 117 118 # _initialized is a flag to indicating the object is in constructor. 119 # It can be used to block any size update to make restoring from json 120 # string faster. For example, if file_details has sub-directories, 121 # all sub-directories will be added to this class recursively, blocking 122 # the size updates can reduce unnecessary calculations. 123 self._initialized = False 124 self._parent_result_info = parent_result_info 125 126 if original_info is None: 127 self._init_from_file(parent_dir, name) 128 else: 129 self._init_with_original_info(parent_dir, original_info) 130 131 # Size of bytes collected in an overwritten or removed directory. 132 self._previous_collected_size = 0 133 self._initialized = True 134 135 def _init_from_file(self, parent_dir, name): 136 """Initialize with the physical file. 137 138 @param parent_dir: Path to the parent directory. 139 @param name: Name of the result file or directory. 140 """ 141 assert name != None 142 self._name = name 143 144 # Dictionary to store details of the given path is set to a keyval of 145 # the wrapper class. Save the dictionary to an attribute for faster 146 # access. 147 self._details = {} 148 self[self.name] = self._details 149 150 # rstrip is to remove / when name is ROOT_DIR (''). 151 self._path = os.path.join(parent_dir, self.name).rstrip(os.sep) 152 self._is_dir = os.path.isdir(self._path) 153 154 if self.is_dir: 155 # The value of key utils_lib.DIRS is a list of ResultInfo objects. 156 self.details[utils_lib.DIRS] = [] 157 158 # Set original size to be the physical size if file details are not 159 # given and the path is for a file. 160 if self.is_dir: 161 # Set directory size to 0, it will be updated later after its 162 # sub-directories are added. 163 self.original_size = 0 164 else: 165 self.original_size = self.size 166 167 def _init_with_original_info(self, parent_dir, original_info): 168 """Initialize with pre-collected information. 169 170 @param parent_dir: Path to the parent directory. 171 @param original_info: A dictionary of the result's size information. 172 This is retrieved from the previously serialized json string. 173 For example: {'file_name': 174 {'/S': 100, '/T': 50} 175 } 176 which means a file's original size is 100 bytes, and trimmed 177 down to 50 bytes. This argument is used when the object is 178 restored from a json string. 179 """ 180 assert original_info 181 # The result information dictionary has only 1 key, which is the file or 182 # directory name. 183 self._name = original_info.keys()[0] 184 185 # Dictionary to store details of the given path is set to a keyval of 186 # the wrapper class. Save the dictionary to an attribute for faster 187 # access. 188 self._details = {} 189 self[self.name] = self._details 190 191 # rstrip is to remove / when name is ROOT_DIR (''). 192 self._path = os.path.join(parent_dir, self.name).rstrip(os.sep) 193 194 self._is_dir = utils_lib.DIRS in original_info[self.name] 195 196 if self.is_dir: 197 # The value of key utils_lib.DIRS is a list of ResultInfo objects. 198 self.details[utils_lib.DIRS] = [] 199 200 # This is restoring ResultInfo from a json string. 201 self.original_size = original_info[self.name][ 202 utils_lib.ORIGINAL_SIZE_BYTES] 203 if utils_lib.TRIMMED_SIZE_BYTES in original_info[self.name]: 204 self.trimmed_size = original_info[self.name][ 205 utils_lib.TRIMMED_SIZE_BYTES] 206 if self.is_dir: 207 for sub_file in original_info[self.name][utils_lib.DIRS]: 208 self.add_file(None, sub_file) 209 210 def update_dir_original_size(self): 211 """Update all directories' original size information. 212 """ 213 for f in [f for f in self.files if f.is_dir]: 214 f.update_dir_original_size() 215 self.update_original_size(skip_parent_update=True) 216 217 @staticmethod 218 def build_from_path(parent_dir, 219 name=utils_lib.ROOT_DIR, 220 parent_result_info=None, top_dir=None, 221 all_dirs=None): 222 """Get the ResultInfo for the given path. 223 224 @param parent_dir: The parent directory of the given file. 225 @param name: Name of the result file or directory. 226 @param parent_result_info: A ResultInfo instance for the parent 227 directory. 228 @param top_dir: The top directory to collect ResultInfo. This is to 229 check if a directory is a subdir of the original directory to 230 collect summary. 231 @param all_dirs: A set of paths that have been collected. This is to 232 prevent infinite recursive call caused by symlink. 233 234 @return: A ResultInfo instance containing the directory summary. 235 """ 236 is_top_level = top_dir is None 237 top_dir = top_dir or parent_dir 238 all_dirs = all_dirs or set() 239 240 # If the given parent_dir is a file and name is ROOT_DIR, that means 241 # the ResultInfo is for a single file with root directory of the default 242 # ROOT_DIR. 243 if not os.path.isdir(parent_dir) and name == utils_lib.ROOT_DIR: 244 root_dir = os.path.dirname(parent_dir) 245 dir_info = ResultInfo(parent_dir=root_dir, 246 name=utils_lib.ROOT_DIR) 247 dir_info.add_file(os.path.basename(parent_dir)) 248 return dir_info 249 250 dir_info = ResultInfo(parent_dir=parent_dir, 251 name=name, 252 parent_result_info=parent_result_info) 253 254 path = os.path.join(parent_dir, name) 255 if os.path.isdir(path): 256 real_path = os.path.realpath(path) 257 # The assumption here is that results are copied back to drone by 258 # copying the symlink, not the content, which is true with currently 259 # used rsync in cros_host.get_file call. 260 # Skip scanning the child folders if any of following condition is 261 # true: 262 # 1. The directory is a symlink and link to a folder under `top_dir` 263 # 2. The directory was scanned already. 264 if ((os.path.islink(path) and real_path.startswith(top_dir)) or 265 real_path in all_dirs): 266 return dir_info 267 all_dirs.add(real_path) 268 for f in sorted(os.listdir(path)): 269 dir_info.files.append(ResultInfo.build_from_path( 270 parent_dir=path, 271 name=f, 272 parent_result_info=dir_info, 273 top_dir=top_dir, 274 all_dirs=all_dirs)) 275 276 # Update all directory's original size at the end of the tree building. 277 if is_top_level: 278 dir_info.update_dir_original_size() 279 280 return dir_info 281 282 @property 283 def details(self): 284 """Get the details of the result. 285 286 @return: A dictionary of size and sub-directory information. 287 """ 288 return self._details 289 290 @property 291 def is_dir(self): 292 """Get if the result is a directory. 293 """ 294 return self._is_dir 295 296 @property 297 def name(self): 298 """Name of the result. 299 """ 300 return self._name 301 302 @property 303 def path(self): 304 """Full path to the result. 305 """ 306 return self._path 307 308 @property 309 def files(self): 310 """All files or sub-directories of the result. 311 312 @return: A list of ResultInfo objects. 313 @raise ResultInfoError: If the result is not a directory. 314 """ 315 if not self.is_dir: 316 raise ResultInfoError('%s is not a directory.' % self.path) 317 return self.details[utils_lib.DIRS] 318 319 @property 320 def size(self): 321 """Physical size in bytes for the result file. 322 323 @raise ResultInfoError: If the result is a directory. 324 """ 325 if self.is_dir: 326 raise ResultInfoError( 327 '`size` property does not support directory. Try to use ' 328 '`original_size` property instead.') 329 return result_info_lib.get_file_size(self._path) 330 331 @property 332 def original_size(self): 333 """The original size in bytes of the result before it's throttled. 334 """ 335 return self.details[utils_lib.ORIGINAL_SIZE_BYTES] 336 337 @original_size.setter 338 def original_size(self, value): 339 """Set the original size in bytes of the result. 340 341 @param value: The original size in bytes of the result. 342 """ 343 self.details[utils_lib.ORIGINAL_SIZE_BYTES] = value 344 # Update the size of parent result infos if the object is already 345 # initialized. 346 if self._initialized and self._parent_result_info is not None: 347 self._parent_result_info.update_original_size() 348 349 @property 350 def trimmed_size(self): 351 """The size in bytes of the result after it's throttled. 352 """ 353 return self.details.get(utils_lib.TRIMMED_SIZE_BYTES, 354 self.original_size) 355 356 @trimmed_size.setter 357 def trimmed_size(self, value): 358 """Set the trimmed size in bytes of the result. 359 360 @param value: The trimmed size in bytes of the result. 361 """ 362 self.details[utils_lib.TRIMMED_SIZE_BYTES] = value 363 # Update the size of parent result infos if the object is already 364 # initialized. 365 if self._initialized and self._parent_result_info is not None: 366 self._parent_result_info.update_trimmed_size() 367 368 @property 369 def collected_size(self): 370 """The collected size in bytes of the result. 371 372 The file is throttled on the dut, so the number of bytes collected from 373 dut is default to the trimmed_size. If a file is modified between 374 multiple result collections and is collected multiple times during the 375 test run, the collected_size will be the sum of the multiple 376 collections. Therefore, its value will be greater than the trimmed_size 377 of the last copy. 378 """ 379 return self.details.get(utils_lib.COLLECTED_SIZE_BYTES, 380 self.trimmed_size) 381 382 @collected_size.setter 383 def collected_size(self, value): 384 """Set the collected size in bytes of the result. 385 386 @param value: The collected size in bytes of the result. 387 """ 388 self.details[utils_lib.COLLECTED_SIZE_BYTES] = value 389 # Update the size of parent result infos if the object is already 390 # initialized. 391 if self._initialized and self._parent_result_info is not None: 392 self._parent_result_info.update_collected_size() 393 394 @property 395 def is_collected_size_recorded(self): 396 """Flag to indicate if the result has collected size set. 397 398 This flag is used to avoid unnecessary entry in result details, as the 399 default value of collected size is the trimmed size. Removing the 400 redundant information helps to reduce the size of the json file. 401 """ 402 return utils_lib.COLLECTED_SIZE_BYTES in self.details 403 404 @property 405 def parent_result_info(self): 406 """The result info of the parent directory. 407 """ 408 return self._parent_result_info 409 410 def add_file(self, name, original_info=None): 411 """Add a file to the result. 412 413 @param name: Name of the file. 414 @param original_info: A dictionary of the file's size and sub-directory 415 information. 416 """ 417 self.details[utils_lib.DIRS].append( 418 ResultInfo(parent_dir=self._path, 419 name=name, 420 parent_result_info=self, 421 original_info=original_info)) 422 # After a new ResultInfo is added, update the sizes if the object is 423 # already initialized. 424 if self._initialized: 425 self.update_sizes() 426 427 def remove_file(self, name): 428 """Remove a file with the given name from the result. 429 430 @param name: Name of the file to be removed. 431 """ 432 self.files.remove(self.get_file(name)) 433 # After a new ResultInfo is removed, update the sizes if the object is 434 # already initialized. 435 if self._initialized: 436 self.update_sizes() 437 438 def get_file_names(self): 439 """Get a set of all the files under the result. 440 """ 441 return set([f.keys()[0] for f in self.files]) 442 443 def get_file(self, name): 444 """Get a file with the given name under the result. 445 446 @param name: Name of the file. 447 @return: A ResultInfo object of the file. 448 @raise ResultInfoError: If the result is not a directory, or the file 449 with the given name is not found. 450 """ 451 if not self.is_dir: 452 raise ResultInfoError('%s is not a directory. Can\'t locate file ' 453 '%s' % (self.path, name)) 454 for file_info in self.files: 455 if file_info.name == name: 456 return file_info 457 raise ResultInfoError('Can\'t locate file %s in directory %s' % 458 (name, self.path)) 459 460 def convert_to_dir(self): 461 """Convert the result file to a directory. 462 463 This happens when a result file was overwritten by a directory. The 464 conversion will reset the details of this result to be a directory, 465 and save the collected_size to attribute `_previous_collected_size`, 466 so it can be counted when merging multiple result infos. 467 468 @raise ResultInfoError: If the result is already a directory. 469 """ 470 if self.is_dir: 471 raise ResultInfoError('%s is already a directory.' % self.path) 472 # The size that's collected before the file was replaced as a directory. 473 collected_size = self.collected_size 474 self._is_dir = True 475 self.details[utils_lib.DIRS] = [] 476 self.original_size = 0 477 self.trimmed_size = 0 478 self._previous_collected_size = collected_size 479 self.collected_size = collected_size 480 481 def update_original_size(self, skip_parent_update=False): 482 """Update the original size of the result and trigger its parent to 483 update. 484 485 @param skip_parent_update: True to skip updating parent directory's 486 original size. Default is set to False. 487 """ 488 if self.is_dir: 489 self.original_size = sum([ 490 f.original_size for f in self.files]) 491 elif self.original_size is None: 492 # Only set original_size if it's not initialized yet. 493 self.orginal_size = self.size 494 495 # Update the size of parent result infos. 496 if not skip_parent_update and self._parent_result_info is not None: 497 self._parent_result_info.update_original_size() 498 499 def update_trimmed_size(self): 500 """Update the trimmed size of the result and trigger its parent to 501 update. 502 """ 503 if self.is_dir: 504 new_trimmed_size = sum([f.trimmed_size for f in self.files]) 505 else: 506 new_trimmed_size = self.size 507 508 # Only set trimmed_size if the value is changed or different from the 509 # original size. 510 if (new_trimmed_size != self.original_size or 511 new_trimmed_size != self.trimmed_size): 512 self.trimmed_size = new_trimmed_size 513 514 # Update the size of parent result infos. 515 if self._parent_result_info is not None: 516 self._parent_result_info.update_trimmed_size() 517 518 def update_collected_size(self): 519 """Update the collected size of the result and trigger its parent to 520 update. 521 """ 522 if self.is_dir: 523 new_collected_size = ( 524 self._previous_collected_size + 525 sum([f.collected_size for f in self.files])) 526 else: 527 new_collected_size = self.size 528 529 # Only set collected_size if the value is changed or different from the 530 # trimmed size or existing collected size. 531 if (new_collected_size != self.trimmed_size or 532 new_collected_size != self.collected_size): 533 self.collected_size = new_collected_size 534 535 # Update the size of parent result infos. 536 if self._parent_result_info is not None: 537 self._parent_result_info.update_collected_size() 538 539 def update_sizes(self): 540 """Update all sizes information of the result. 541 """ 542 self.update_original_size() 543 self.update_trimmed_size() 544 self.update_collected_size() 545 546 def set_parent_result_info(self, parent_result_info): 547 """Set the parent result info. 548 549 It's used when a ResultInfo object is moved to a different file 550 structure. 551 552 @param parent_result_info: A ResultInfo object for the parent directory. 553 """ 554 self._parent_result_info = parent_result_info 555 # As the parent reference changed, update all sizes of the parent. 556 if parent_result_info: 557 self._parent_result_info.update_sizes() 558 559 def merge(self, new_info, is_final=False): 560 """Merge a ResultInfo instance to the current one. 561 562 Update the old directory's ResultInfo with the new one. Also calculate 563 the total size of results collected from the client side based on the 564 difference between the two ResultInfo. 565 566 When merging with newer collected results, any results not existing in 567 the new ResultInfo or files with size different from the newer files 568 collected are considered as extra results collected or overwritten by 569 the new results. 570 Therefore, the size of the collected result should include such files, 571 and the collected size can be larger than trimmed size. 572 As an example: 573 current: {'file1': {TRIMMED_SIZE_BYTES: 1024, 574 ORIGINAL_SIZE_BYTES: 1024, 575 COLLECTED_SIZE_BYTES: 1024}} 576 This means a result `file1` of original size 1KB was collected with size 577 of 1KB byte. 578 new_info: {'file1': {TRIMMED_SIZE_BYTES: 1024, 579 ORIGINAL_SIZE_BYTES: 2048, 580 COLLECTED_SIZE_BYTES: 1024}} 581 This means a result `file1` of 2KB was trimmed down to 1KB and was 582 collected with size of 1KB byte. 583 Note that the second result collection has an updated result `file1` 584 (because of the different ORIGINAL_SIZE_BYTES), and it needs to be 585 rsync-ed to the drone. Therefore, the merged ResultInfo will be: 586 {'file1': {TRIMMED_SIZE_BYTES: 1024, 587 ORIGINAL_SIZE_BYTES: 2048, 588 COLLECTED_SIZE_BYTES: 2048}} 589 Note that: 590 * TRIMMED_SIZE_BYTES is still at 1KB, which reflects the actual size of 591 the file be collected. 592 * ORIGINAL_SIZE_BYTES is updated to 2KB, which is the size of the file 593 in the new result `file1`. 594 * COLLECTED_SIZE_BYTES is 2KB because rsync will copy `file1` twice as 595 it's changed. 596 597 The only exception is that the new ResultInfo's ORIGINAL_SIZE_BYTES is 598 the same as the current ResultInfo's TRIMMED_SIZE_BYTES. That means the 599 file was trimmed in the current ResultInfo and the new ResultInfo is 600 collecting the trimmed file. Therefore, the merged summary will keep the 601 data in the current ResultInfo. 602 603 @param new_info: New ResultInfo to be merged into the current one. 604 @param is_final: True if new_info is built from the final result folder. 605 Default is set to False. 606 """ 607 new_files = new_info.get_file_names() 608 old_files = self.get_file_names() 609 for name in new_files: 610 new_file = new_info.get_file(name) 611 if not name in old_files: 612 # A file/dir exists in new client dir, but not in the old one, 613 # which means that the file or a directory is newly collected. 614 copy_file = copy.deepcopy(new_file) 615 self.files.append(copy_file) 616 copy_file.set_parent_result_info(self) 617 elif new_file.is_dir: 618 # `name` is a directory in the new ResultInfo, try to merge it 619 # with the current ResultInfo. 620 old_file = self.get_file(name) 621 622 if not old_file.is_dir: 623 # If `name` is a file in the current ResultInfo but a 624 # directory in new ResultInfo, the file in the current 625 # ResultInfo will be overwritten by the new directory by 626 # rsync. Therefore, force it to be an empty directory in 627 # the current ResultInfo, so that the new directory can be 628 # merged. 629 old_file.convert_to_dir() 630 631 old_file.merge(new_file, is_final) 632 else: 633 old_file = self.get_file(name) 634 635 # If `name` is a directory in the current ResultInfo, but a file 636 # in the new ResultInfo, rsync will fail to copy the file as it 637 # can't overwrite an directory. Therefore, skip the merge. 638 if old_file.is_dir: 639 continue 640 641 new_size = new_file.original_size 642 old_size = old_file.original_size 643 new_trimmed_size = new_file.trimmed_size 644 old_trimmed_size = old_file.trimmed_size 645 646 # Keep current information if the sizes are not changed. 647 if (new_size == old_size and 648 new_trimmed_size == old_trimmed_size): 649 continue 650 651 # Keep current information if the newer size is the same as the 652 # current trimmed size, and the file is not trimmed in new 653 # ResultInfo. That means the file was trimmed earlier and stays 654 # the same when collecting the information again. 655 if (new_size == old_trimmed_size and 656 new_size == new_trimmed_size): 657 continue 658 659 # If the file is merged from the final result folder to an older 660 # ResultInfo, it's not considered to be trimmed if the size is 661 # not changed. The reason is that the file on the server side 662 # does not have the info of its original size. 663 if is_final and new_trimmed_size == old_trimmed_size: 664 continue 665 666 # `name` is a file, and both the original_size and trimmed_size 667 # are changed, that means the file is overwritten, so increment 668 # the collected_size. 669 # Before trimming is implemented, collected_size is the 670 # value of original_size. 671 new_collected_size = new_file.collected_size 672 old_collected_size = old_file.collected_size 673 674 old_file.collected_size = ( 675 new_collected_size + old_collected_size) 676 # Only set trimmed_size if one of the following two conditions 677 # are true: 678 # 1. In the new summary the file's trimmed size is different 679 # from the original size, which means the file was trimmed 680 # in the new summary. 681 # 2. The original size in the new summary equals the trimmed 682 # size in the old summary, which means the file was trimmed 683 # again in the new summary. 684 if (new_size == old_trimmed_size or 685 new_size != new_trimmed_size): 686 old_file.trimmed_size = new_file.trimmed_size 687 old_file.original_size = new_size 688 689 690# An empty directory, used to compare with a ResultInfo. 691EMPTY = ResultInfo(parent_dir='', 692 original_info={'': {utils_lib.ORIGINAL_SIZE_BYTES: 0, 693 utils_lib.DIRS: []}}) 694 695 696def save_summary(summary, json_file): 697 """Save the given directory summary to a file. 698 699 @param summary: A ResultInfo object for a result directory. 700 @param json_file: Path to a json file to save to. 701 """ 702 with open(json_file, 'w') as f: 703 json.dump(summary, f) 704 705 706def load_summary_json_file(json_file): 707 """Load result info from the given json_file. 708 709 @param json_file: Path to a json file containing a directory summary. 710 @return: A ResultInfo object containing the directory summary. 711 """ 712 with open(json_file, 'r') as f: 713 summary = json.load(f) 714 715 # Convert summary to ResultInfo objects 716 result_dir = os.path.dirname(json_file) 717 return ResultInfo(parent_dir=result_dir, original_info=summary) 718