Home | History | Annotate | Download | only in result_tools
      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 
      8 import contextlib
      9 import json
     10 import os
     11 
     12 import result_info_lib
     13 import utils_lib
     14 
     15 
     16 class ResultInfoError(Exception):
     17     """Exception to raise when error occurs in ResultInfo collection."""
     18 
     19 
     20 class 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`
     94            should 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             dirs = original_info[self.name][utils_lib.DIRS]
    208             # TODO: Remove this conversion after R62 is in stable channel.
    209             if isinstance(dirs, dict):
    210                 # The summary is generated from older format which stores sub-
    211                 # directories in a dictionary, rather than a list. Convert the
    212                 # data in old format to a list of dictionary.
    213                 dirs = [{dir_name: dirs[dir_name]} for dir_name in dirs]
    214             for sub_file in dirs:
    215                 self.add_file(None, sub_file)
    216 
    217     @contextlib.contextmanager
    218     def disable_updating_parent_size_info(self):
    219         """Disable recursive calls to update parent result_info's sizes.
    220 
    221         This context manager allows removing sub-directories to run faster
    222         without triggering recursive calls to update parent result_info's sizes.
    223         """
    224         old_value = self._initialized
    225         self._initialized = False
    226         try:
    227             yield
    228         finally:
    229             self._initialized = old_value
    230 
    231     def update_dir_original_size(self):
    232         """Update all directories' original size information.
    233         """
    234         for f in [f for f in self.files if f.is_dir]:
    235             f.update_dir_original_size()
    236         self.update_original_size(skip_parent_update=True)
    237 
    238     @staticmethod
    239     def build_from_path(parent_dir,
    240                         name=utils_lib.ROOT_DIR,
    241                         parent_result_info=None, top_dir=None,
    242                         all_dirs=None):
    243         """Get the ResultInfo for the given path.
    244 
    245         @param parent_dir: The parent directory of the given file.
    246         @param name: Name of the result file or directory.
    247         @param parent_result_info: A ResultInfo instance for the parent
    248                 directory.
    249         @param top_dir: The top directory to collect ResultInfo. This is to
    250                 check if a directory is a subdir of the original directory to
    251                 collect summary.
    252         @param all_dirs: A set of paths that have been collected. This is to
    253                 prevent infinite recursive call caused by symlink.
    254 
    255         @return: A ResultInfo instance containing the directory summary.
    256         """
    257         is_top_level = top_dir is None
    258         top_dir = top_dir or parent_dir
    259         all_dirs = all_dirs or set()
    260 
    261         # If the given parent_dir is a file and name is ROOT_DIR, that means
    262         # the ResultInfo is for a single file with root directory of the default
    263         # ROOT_DIR.
    264         if not os.path.isdir(parent_dir) and name == utils_lib.ROOT_DIR:
    265             root_dir = os.path.dirname(parent_dir)
    266             dir_info = ResultInfo(parent_dir=root_dir,
    267                                   name=utils_lib.ROOT_DIR)
    268             dir_info.add_file(os.path.basename(parent_dir))
    269             return dir_info
    270 
    271         dir_info = ResultInfo(parent_dir=parent_dir,
    272                               name=name,
    273                               parent_result_info=parent_result_info)
    274 
    275         path = os.path.join(parent_dir, name)
    276         if os.path.isdir(path):
    277             real_path = os.path.realpath(path)
    278             # The assumption here is that results are copied back to drone by
    279             # copying the symlink, not the content, which is true with currently
    280             # used rsync in cros_host.get_file call.
    281             # Skip scanning the child folders if any of following condition is
    282             # true:
    283             # 1. The directory is a symlink and link to a folder under `top_dir`
    284             # 2. The directory was scanned already.
    285             if ((os.path.islink(path) and real_path.startswith(top_dir)) or
    286                 real_path in all_dirs):
    287                 return dir_info
    288             all_dirs.add(real_path)
    289             for f in sorted(os.listdir(path)):
    290                 dir_info.files.append(ResultInfo.build_from_path(
    291                         parent_dir=path,
    292                         name=f,
    293                         parent_result_info=dir_info,
    294                         top_dir=top_dir,
    295                         all_dirs=all_dirs))
    296 
    297         # Update all directory's original size at the end of the tree building.
    298         if is_top_level:
    299             dir_info.update_dir_original_size()
    300 
    301         return dir_info
    302 
    303     @property
    304     def details(self):
    305         """Get the details of the result.
    306 
    307         @return: A dictionary of size and sub-directory information.
    308         """
    309         return self._details
    310 
    311     @property
    312     def is_dir(self):
    313         """Get if the result is a directory.
    314         """
    315         return self._is_dir
    316 
    317     @property
    318     def name(self):
    319         """Name of the result.
    320         """
    321         return self._name
    322 
    323     @property
    324     def path(self):
    325         """Full path to the result.
    326         """
    327         return self._path
    328 
    329     @property
    330     def files(self):
    331         """All files or sub-directories of the result.
    332 
    333         @return: A list of ResultInfo objects.
    334         @raise ResultInfoError: If the result is not a directory.
    335         """
    336         if not self.is_dir:
    337             raise ResultInfoError('%s is not a directory.' % self.path)
    338         return self.details[utils_lib.DIRS]
    339 
    340     @property
    341     def size(self):
    342         """Physical size in bytes for the result file.
    343 
    344         @raise ResultInfoError: If the result is a directory.
    345         """
    346         if self.is_dir:
    347             raise ResultInfoError(
    348                     '`size` property does not support directory. Try to use '
    349                     '`original_size` property instead.')
    350         return result_info_lib.get_file_size(self._path)
    351 
    352     @property
    353     def original_size(self):
    354         """The original size in bytes of the result before it's throttled.
    355         """
    356         return self.details[utils_lib.ORIGINAL_SIZE_BYTES]
    357 
    358     @original_size.setter
    359     def original_size(self, value):
    360         """Set the original size in bytes of the result.
    361 
    362         @param value: The original size in bytes of the result.
    363         """
    364         self.details[utils_lib.ORIGINAL_SIZE_BYTES] = value
    365         # Update the size of parent result infos if the object is already
    366         # initialized.
    367         if self._initialized and self._parent_result_info is not None:
    368             self._parent_result_info.update_original_size()
    369 
    370     @property
    371     def trimmed_size(self):
    372         """The size in bytes of the result after it's throttled.
    373         """
    374         return self.details.get(utils_lib.TRIMMED_SIZE_BYTES,
    375                                 self.original_size)
    376 
    377     @trimmed_size.setter
    378     def trimmed_size(self, value):
    379         """Set the trimmed size in bytes of the result.
    380 
    381         @param value: The trimmed size in bytes of the result.
    382         """
    383         self.details[utils_lib.TRIMMED_SIZE_BYTES] = value
    384         # Update the size of parent result infos if the object is already
    385         # initialized.
    386         if self._initialized and self._parent_result_info is not None:
    387             self._parent_result_info.update_trimmed_size()
    388 
    389     @property
    390     def collected_size(self):
    391         """The collected size in bytes of the result.
    392 
    393         The file is throttled on the dut, so the number of bytes collected from
    394         dut is default to the trimmed_size. If a file is modified between
    395         multiple result collections and is collected multiple times during the
    396         test run, the collected_size will be the sum of the multiple
    397         collections. Therefore, its value will be greater than the trimmed_size
    398         of the last copy.
    399         """
    400         return self.details.get(utils_lib.COLLECTED_SIZE_BYTES,
    401                                 self.trimmed_size)
    402 
    403     @collected_size.setter
    404     def collected_size(self, value):
    405         """Set the collected size in bytes of the result.
    406 
    407         @param value: The collected size in bytes of the result.
    408         """
    409         self.details[utils_lib.COLLECTED_SIZE_BYTES] = value
    410         # Update the size of parent result infos if the object is already
    411         # initialized.
    412         if self._initialized and self._parent_result_info is not None:
    413             self._parent_result_info.update_collected_size()
    414 
    415     @property
    416     def is_collected_size_recorded(self):
    417         """Flag to indicate if the result has collected size set.
    418 
    419         This flag is used to avoid unnecessary entry in result details, as the
    420         default value of collected size is the trimmed size. Removing the
    421         redundant information helps to reduce the size of the json file.
    422         """
    423         return utils_lib.COLLECTED_SIZE_BYTES in self.details
    424 
    425     @property
    426     def parent_result_info(self):
    427         """The result info of the parent directory.
    428         """
    429         return self._parent_result_info
    430 
    431     def add_file(self, name, original_info=None):
    432         """Add a file to the result.
    433 
    434         @param name: Name of the file.
    435         @param original_info: A dictionary of the file's size and sub-directory
    436                 information.
    437         """
    438         self.details[utils_lib.DIRS].append(
    439                 ResultInfo(parent_dir=self._path,
    440                            name=name,
    441                            parent_result_info=self,
    442                            original_info=original_info))
    443         # After a new ResultInfo is added, update the sizes if the object is
    444         # already initialized.
    445         if self._initialized:
    446             self.update_sizes()
    447 
    448     def remove_file(self, name):
    449         """Remove a file with the given name from the result.
    450 
    451         @param name: Name of the file to be removed.
    452         """
    453         self.files.remove(self.get_file(name))
    454         # After a new ResultInfo is removed, update the sizes if the object is
    455         # already initialized.
    456         if self._initialized:
    457             self.update_sizes()
    458 
    459     def get_file_names(self):
    460         """Get a set of all the files under the result.
    461         """
    462         return set([f.keys()[0] for f in self.files])
    463 
    464     def get_file(self, name):
    465         """Get a file with the given name under the result.
    466 
    467         @param name: Name of the file.
    468         @return: A ResultInfo object of the file.
    469         @raise ResultInfoError: If the result is not a directory, or the file
    470                 with the given name is not found.
    471         """
    472         if not self.is_dir:
    473             raise ResultInfoError('%s is not a directory. Can\'t locate file '
    474                                   '%s' % (self.path, name))
    475         for file_info in self.files:
    476             if file_info.name == name:
    477                 return file_info
    478         raise ResultInfoError('Can\'t locate file %s in directory %s' %
    479                               (name, self.path))
    480 
    481     def convert_to_dir(self):
    482         """Convert the result file to a directory.
    483 
    484         This happens when a result file was overwritten by a directory. The
    485         conversion will reset the details of this result to be a directory,
    486         and save the collected_size to attribute `_previous_collected_size`,
    487         so it can be counted when merging multiple result infos.
    488 
    489         @raise ResultInfoError: If the result is already a directory.
    490         """
    491         if self.is_dir:
    492             raise ResultInfoError('%s is already a directory.' % self.path)
    493         # The size that's collected before the file was replaced as a directory.
    494         collected_size = self.collected_size
    495         self._is_dir = True
    496         self.details[utils_lib.DIRS] = []
    497         self.original_size = 0
    498         self.trimmed_size = 0
    499         self._previous_collected_size = collected_size
    500         self.collected_size = collected_size
    501 
    502     def update_original_size(self, skip_parent_update=False):
    503         """Update the original size of the result and trigger its parent to
    504         update.
    505 
    506         @param skip_parent_update: True to skip updating parent directory's
    507                 original size. Default is set to False.
    508         """
    509         if self.is_dir:
    510             self.original_size = sum([
    511                     f.original_size for f in self.files])
    512         elif self.original_size is None:
    513             # Only set original_size if it's not initialized yet.
    514             self.orginal_size = self.size
    515 
    516         # Update the size of parent result infos.
    517         if not skip_parent_update and self._parent_result_info is not None:
    518             self._parent_result_info.update_original_size()
    519 
    520     def update_trimmed_size(self):
    521         """Update the trimmed size of the result and trigger its parent to
    522         update.
    523         """
    524         if self.is_dir:
    525             new_trimmed_size = sum([f.trimmed_size for f in self.files])
    526         else:
    527             new_trimmed_size = self.size
    528 
    529         # Only set trimmed_size if the value is changed or different from the
    530         # original size.
    531         if (new_trimmed_size != self.original_size or
    532             new_trimmed_size != self.trimmed_size):
    533             self.trimmed_size = new_trimmed_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_trimmed_size()
    538 
    539     def update_collected_size(self):
    540         """Update the collected size of the result and trigger its parent to
    541         update.
    542         """
    543         if self.is_dir:
    544             new_collected_size = (
    545                     self._previous_collected_size +
    546                     sum([f.collected_size for f in self.files]))
    547         else:
    548             new_collected_size = self.size
    549 
    550         # Only set collected_size if the value is changed or different from the
    551         # trimmed size or existing collected size.
    552         if (new_collected_size != self.trimmed_size or
    553             new_collected_size != self.collected_size):
    554             self.collected_size = new_collected_size
    555 
    556         # Update the size of parent result infos.
    557         if self._parent_result_info is not None:
    558             self._parent_result_info.update_collected_size()
    559 
    560     def update_sizes(self):
    561         """Update all sizes information of the result.
    562         """
    563         self.update_original_size()
    564         self.update_trimmed_size()
    565         self.update_collected_size()
    566 
    567     def set_parent_result_info(self, parent_result_info, update_sizes=True):
    568         """Set the parent result info.
    569 
    570         It's used when a ResultInfo object is moved to a different file
    571         structure.
    572 
    573         @param parent_result_info: A ResultInfo object for the parent directory.
    574         @param update_sizes: True to update the parent's size information. Set
    575                 it to False to delay the update for better performance.
    576         """
    577         self._parent_result_info = parent_result_info
    578         # As the parent reference changed, update all sizes of the parent.
    579         if parent_result_info and update_sizes:
    580             self._parent_result_info.update_sizes()
    581 
    582     def merge(self, new_info, is_final=False):
    583         """Merge a ResultInfo instance to the current one.
    584 
    585         Update the old directory's ResultInfo with the new one. Also calculate
    586         the total size of results collected from the client side based on the
    587         difference between the two ResultInfo.
    588 
    589         When merging with newer collected results, any results not existing in
    590         the new ResultInfo or files with size different from the newer files
    591         collected are considered as extra results collected or overwritten by
    592         the new results.
    593         Therefore, the size of the collected result should include such files,
    594         and the collected size can be larger than trimmed size.
    595         As an example:
    596         current: {'file1': {TRIMMED_SIZE_BYTES: 1024,
    597                             ORIGINAL_SIZE_BYTES: 1024,
    598                             COLLECTED_SIZE_BYTES: 1024}}
    599         This means a result `file1` of original size 1KB was collected with size
    600         of 1KB byte.
    601         new_info: {'file1': {TRIMMED_SIZE_BYTES: 1024,
    602                              ORIGINAL_SIZE_BYTES: 2048,
    603                              COLLECTED_SIZE_BYTES: 1024}}
    604         This means a result `file1` of 2KB was trimmed down to 1KB and was
    605         collected with size of 1KB byte.
    606         Note that the second result collection has an updated result `file1`
    607         (because of the different ORIGINAL_SIZE_BYTES), and it needs to be
    608         rsync-ed to the drone. Therefore, the merged ResultInfo will be:
    609         {'file1': {TRIMMED_SIZE_BYTES: 1024,
    610                    ORIGINAL_SIZE_BYTES: 2048,
    611                    COLLECTED_SIZE_BYTES: 2048}}
    612         Note that:
    613         * TRIMMED_SIZE_BYTES is still at 1KB, which reflects the actual size of
    614           the file be collected.
    615         * ORIGINAL_SIZE_BYTES is updated to 2KB, which is the size of the file
    616           in the new result `file1`.
    617         * COLLECTED_SIZE_BYTES is 2KB because rsync will copy `file1` twice as
    618           it's changed.
    619 
    620         The only exception is that the new ResultInfo's ORIGINAL_SIZE_BYTES is
    621         the same as the current ResultInfo's TRIMMED_SIZE_BYTES. That means the
    622         file was trimmed in the current ResultInfo and the new ResultInfo is
    623         collecting the trimmed file. Therefore, the merged summary will keep the
    624         data in the current ResultInfo.
    625 
    626         @param new_info: New ResultInfo to be merged into the current one.
    627         @param is_final: True if new_info is built from the final result folder.
    628                 Default is set to False.
    629         """
    630         new_files = new_info.get_file_names()
    631         old_files = self.get_file_names()
    632         # A flag to indicate if the sizes need to be updated. It's required when
    633         # child result_info is added to `self`.
    634         update_sizes_pending = False
    635         for name in new_files:
    636             new_file = new_info.get_file(name)
    637             if not name in old_files:
    638                 # A file/dir exists in new client dir, but not in the old one,
    639                 # which means that the file or a directory is newly collected.
    640                 self.files.append(new_file)
    641                 # Once parent_result_info is changed, new_file object will no
    642                 # longer associated with `new_info` object.
    643                 new_file.set_parent_result_info(self, update_sizes=False)
    644                 update_sizes_pending = True
    645             elif new_file.is_dir:
    646                 # `name` is a directory in the new ResultInfo, try to merge it
    647                 # with the current ResultInfo.
    648                 old_file = self.get_file(name)
    649 
    650                 if not old_file.is_dir:
    651                     # If `name` is a file in the current ResultInfo but a
    652                     # directory in new ResultInfo, the file in the current
    653                     # ResultInfo will be overwritten by the new directory by
    654                     # rsync. Therefore, force it to be an empty directory in
    655                     # the current ResultInfo, so that the new directory can be
    656                     # merged.
    657                     old_file.convert_to_dir()
    658 
    659                 old_file.merge(new_file, is_final)
    660             else:
    661                 old_file = self.get_file(name)
    662 
    663                 # If `name` is a directory in the current ResultInfo, but a file
    664                 # in the new ResultInfo, rsync will fail to copy the file as it
    665                 # can't overwrite an directory. Therefore, skip the merge.
    666                 if old_file.is_dir:
    667                     continue
    668 
    669                 new_size = new_file.original_size
    670                 old_size = old_file.original_size
    671                 new_trimmed_size = new_file.trimmed_size
    672                 old_trimmed_size = old_file.trimmed_size
    673 
    674                 # Keep current information if the sizes are not changed.
    675                 if (new_size == old_size and
    676                     new_trimmed_size == old_trimmed_size):
    677                     continue
    678 
    679                 # Keep current information if the newer size is the same as the
    680                 # current trimmed size, and the file is not trimmed in new
    681                 # ResultInfo. That means the file was trimmed earlier and stays
    682                 # the same when collecting the information again.
    683                 if (new_size == old_trimmed_size and
    684                     new_size == new_trimmed_size):
    685                     continue
    686 
    687                 # If the file is merged from the final result folder to an older
    688                 # ResultInfo, it's not considered to be trimmed if the size is
    689                 # not changed. The reason is that the file on the server side
    690                 # does not have the info of its original size.
    691                 if is_final and new_trimmed_size == old_trimmed_size:
    692                     continue
    693 
    694                 # `name` is a file, and both the original_size and trimmed_size
    695                 # are changed, that means the file is overwritten, so increment
    696                 # the collected_size.
    697                 # Before trimming is implemented, collected_size is the
    698                 # value of original_size.
    699                 new_collected_size = new_file.collected_size
    700                 old_collected_size = old_file.collected_size
    701 
    702                 old_file.collected_size = (
    703                         new_collected_size + old_collected_size)
    704                 # Only set trimmed_size if one of the following two conditions
    705                 # are true:
    706                 # 1. In the new summary the file's trimmed size is different
    707                 #    from the original size, which means the file was trimmed
    708                 #    in the new summary.
    709                 # 2. The original size in the new summary equals the trimmed
    710                 #    size in the old summary, which means the file was trimmed
    711                 #    again in the new summary.
    712                 if (new_size == old_trimmed_size or
    713                     new_size != new_trimmed_size):
    714                     old_file.trimmed_size = new_file.trimmed_size
    715                 old_file.original_size = new_size
    716 
    717         if update_sizes_pending:
    718             self.update_sizes()
    719 
    720 
    721 # An empty directory, used to compare with a ResultInfo.
    722 EMPTY = ResultInfo(parent_dir='',
    723                    original_info={'': {utils_lib.ORIGINAL_SIZE_BYTES: 0,
    724                                        utils_lib.DIRS: []}})
    725 
    726 
    727 def save_summary(summary, json_file):
    728     """Save the given directory summary to a file.
    729 
    730     @param summary: A ResultInfo object for a result directory.
    731     @param json_file: Path to a json file to save to.
    732     """
    733     with open(json_file, 'w') as f:
    734         json.dump(summary, f)
    735 
    736 
    737 def load_summary_json_file(json_file):
    738     """Load result info from the given json_file.
    739 
    740     @param json_file: Path to a json file containing a directory summary.
    741     @return: A ResultInfo object containing the directory summary.
    742     """
    743     with open(json_file, 'r') as f:
    744         summary = json.load(f)
    745 
    746     # Convert summary to ResultInfo objects
    747     result_dir = os.path.dirname(json_file)
    748     return ResultInfo(parent_dir=result_dir, original_info=summary)
    749