1#!/usr/bin/env python3
2
3"""
4This script is for comparing the size of the library files from two
5different Git revisions within an Mbed TLS repository.
6The results of the comparison is formatted as csv and stored at a
7configurable location.
8Note: must be run from Mbed TLS root.
9"""
10
11# Copyright The Mbed TLS Contributors
12# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
13
14import argparse
15import logging
16import os
17import re
18import shutil
19import subprocess
20import sys
21import typing
22from enum import Enum
23
24import framework_scripts_path # pylint: disable=unused-import
25from mbedtls_framework import build_tree
26from mbedtls_framework import logging_util
27from mbedtls_framework import typing_util
28
29class SupportedArch(Enum):
30    """Supported architecture for code size measurement."""
31    AARCH64 = 'aarch64'
32    AARCH32 = 'aarch32'
33    ARMV8_M = 'armv8-m'
34    X86_64 = 'x86_64'
35    X86 = 'x86'
36
37
38class SupportedConfig(Enum):
39    """Supported configuration for code size measurement."""
40    DEFAULT = 'default'
41    TFM_MEDIUM = 'tfm-medium'
42
43
44# Static library
45MBEDTLS_STATIC_LIB = {
46    'CRYPTO': 'library/libmbedcrypto.a',
47    'X509': 'library/libmbedx509.a',
48    'TLS': 'library/libmbedtls.a',
49}
50
51class CodeSizeDistinctInfo: # pylint: disable=too-few-public-methods
52    """Data structure to store possibly distinct information for code size
53    comparison."""
54    def __init__( #pylint: disable=too-many-arguments
55            self,
56            version: str,
57            git_rev: str,
58            arch: str,
59            config: str,
60            compiler: str,
61            opt_level: str,
62    ) -> None:
63        """
64        :param: version: which version to compare with for code size.
65        :param: git_rev: Git revision to calculate code size.
66        :param: arch: architecture to measure code size on.
67        :param: config: Configuration type to calculate code size.
68                        (See SupportedConfig)
69        :param: compiler: compiler used to build library/*.o.
70        :param: opt_level: Options that control optimization. (E.g. -Os)
71        """
72        self.version = version
73        self.git_rev = git_rev
74        self.arch = arch
75        self.config = config
76        self.compiler = compiler
77        self.opt_level = opt_level
78        # Note: Variables below are not initialized by class instantiation.
79        self.pre_make_cmd = [] #type: typing.List[str]
80        self.make_cmd = ''
81
82    def get_info_indication(self):
83        """Return a unique string to indicate Code Size Distinct Information."""
84        return '{git_rev}-{arch}-{config}-{compiler}'.format(**self.__dict__)
85
86
87class CodeSizeCommonInfo: # pylint: disable=too-few-public-methods
88    """Data structure to store common information for code size comparison."""
89    def __init__(
90            self,
91            host_arch: str,
92            measure_cmd: str,
93    ) -> None:
94        """
95        :param host_arch: host architecture.
96        :param measure_cmd: command to measure code size for library/*.o.
97        """
98        self.host_arch = host_arch
99        self.measure_cmd = measure_cmd
100
101    def get_info_indication(self):
102        """Return a unique string to indicate Code Size Common Information."""
103        return '{measure_tool}'\
104               .format(measure_tool=self.measure_cmd.strip().split(' ')[0])
105
106class CodeSizeResultInfo: # pylint: disable=too-few-public-methods
107    """Data structure to store result options for code size comparison."""
108    def __init__( #pylint: disable=too-many-arguments
109            self,
110            record_dir: str,
111            comp_dir: str,
112            with_markdown=False,
113            stdout=False,
114            show_all=False,
115    ) -> None:
116        """
117        :param record_dir: directory to store code size record.
118        :param comp_dir: directory to store results of code size comparision.
119        :param with_markdown: write comparision result into a markdown table.
120                              (Default: False)
121        :param stdout: direct comparison result into sys.stdout.
122                       (Default False)
123        :param show_all: show all objects in comparison result. (Default False)
124        """
125        self.record_dir = record_dir
126        self.comp_dir = comp_dir
127        self.with_markdown = with_markdown
128        self.stdout = stdout
129        self.show_all = show_all
130
131
132DETECT_ARCH_CMD = "cc -dM -E - < /dev/null"
133def detect_arch() -> str:
134    """Auto-detect host architecture."""
135    cc_output = subprocess.check_output(DETECT_ARCH_CMD, shell=True).decode()
136    if '__aarch64__' in cc_output:
137        return SupportedArch.AARCH64.value
138    if '__arm__' in cc_output:
139        return SupportedArch.AARCH32.value
140    if '__x86_64__' in cc_output:
141        return SupportedArch.X86_64.value
142    if '__i386__' in cc_output:
143        return SupportedArch.X86.value
144    else:
145        print("Unknown host architecture, cannot auto-detect arch.")
146        sys.exit(1)
147
148TFM_MEDIUM_CONFIG_H = 'configs/ext/tfm_mbedcrypto_config_profile_medium.h'
149TFM_MEDIUM_CRYPTO_CONFIG_H = 'tf-psa-crypto/configs/ext/crypto_config_profile_medium.h'
150
151CONFIG_H = 'include/mbedtls/mbedtls_config.h'
152CRYPTO_CONFIG_H = 'tf-psa-crypto/include/psa/crypto_config.h'
153BACKUP_SUFFIX = '.code_size.bak'
154
155class CodeSizeBuildInfo: # pylint: disable=too-few-public-methods
156    """Gather information used to measure code size.
157
158    It collects information about architecture, configuration in order to
159    infer build command for code size measurement.
160    """
161
162    SupportedArchConfig = [
163        '-a ' + SupportedArch.AARCH64.value + ' -c ' + SupportedConfig.DEFAULT.value,
164        '-a ' + SupportedArch.AARCH32.value + ' -c ' + SupportedConfig.DEFAULT.value,
165        '-a ' + SupportedArch.X86_64.value  + ' -c ' + SupportedConfig.DEFAULT.value,
166        '-a ' + SupportedArch.X86.value     + ' -c ' + SupportedConfig.DEFAULT.value,
167        '-a ' + SupportedArch.ARMV8_M.value + ' -c ' + SupportedConfig.TFM_MEDIUM.value,
168    ]
169
170    def __init__(
171            self,
172            size_dist_info: CodeSizeDistinctInfo,
173            host_arch: str,
174            logger: logging.Logger,
175    ) -> None:
176        """
177        :param size_dist_info:
178            CodeSizeDistinctInfo containing info for code size measurement.
179                - size_dist_info.arch: architecture to measure code size on.
180                - size_dist_info.config: configuration type to measure
181                                         code size with.
182                - size_dist_info.compiler: compiler used to build library/*.o.
183                - size_dist_info.opt_level: Options that control optimization.
184                                            (E.g. -Os)
185        :param host_arch: host architecture.
186        :param logger: logging module
187        """
188        self.arch = size_dist_info.arch
189        self.config = size_dist_info.config
190        self.compiler = size_dist_info.compiler
191        self.opt_level = size_dist_info.opt_level
192
193        self.make_cmd = ['make', '-j', 'lib']
194
195        self.host_arch = host_arch
196        self.logger = logger
197
198    def check_correctness(self) -> bool:
199        """Check whether we are using proper / supported combination
200        of information to build library/*.o."""
201
202        # default config
203        if self.config == SupportedConfig.DEFAULT.value and \
204            self.arch == self.host_arch:
205            return True
206        # TF-M
207        elif self.arch == SupportedArch.ARMV8_M.value and \
208             self.config == SupportedConfig.TFM_MEDIUM.value:
209            return True
210
211        return False
212
213    def infer_pre_make_command(self) -> typing.List[str]:
214        """Infer command to set up proper configuration before running make."""
215        pre_make_cmd = [] #type: typing.List[str]
216        if self.config == SupportedConfig.TFM_MEDIUM.value:
217            pre_make_cmd.append('cp {src} {dest}'
218                                .format(src=TFM_MEDIUM_CONFIG_H, dest=CONFIG_H))
219            pre_make_cmd.append('cp {src} {dest}'
220                                .format(src=TFM_MEDIUM_CRYPTO_CONFIG_H,
221                                        dest=CRYPTO_CONFIG_H))
222
223        return pre_make_cmd
224
225    def infer_make_cflags(self) -> str:
226        """Infer CFLAGS by instance attributes in CodeSizeDistinctInfo."""
227        cflags = [] #type: typing.List[str]
228
229        # set optimization level
230        cflags.append(self.opt_level)
231        # set compiler by config
232        if self.config == SupportedConfig.TFM_MEDIUM.value:
233            self.compiler = 'armclang'
234            cflags.append('-mcpu=cortex-m33')
235        # set target
236        if self.compiler == 'armclang':
237            cflags.append('--target=arm-arm-none-eabi')
238
239        return ' '.join(cflags)
240
241    def infer_make_command(self) -> str:
242        """Infer make command by CFLAGS and CC."""
243
244        if self.check_correctness():
245            # set CFLAGS=
246            self.make_cmd.append('CFLAGS=\'{}\''.format(self.infer_make_cflags()))
247            # set CC=
248            self.make_cmd.append('CC={}'.format(self.compiler))
249            return ' '.join(self.make_cmd)
250        else:
251            self.logger.error("Unsupported combination of architecture: {} " \
252                              "and configuration: {}.\n"
253                              .format(self.arch,
254                                      self.config))
255            self.logger.error("Please use supported combination of " \
256                             "architecture and configuration:")
257            for comb in CodeSizeBuildInfo.SupportedArchConfig:
258                self.logger.error(comb)
259            self.logger.error("")
260            self.logger.error("For your system, please use:")
261            for comb in CodeSizeBuildInfo.SupportedArchConfig:
262                if "default" in comb and self.host_arch not in comb:
263                    continue
264                self.logger.error(comb)
265            sys.exit(1)
266
267
268class CodeSizeCalculator:
269    """ A calculator to calculate code size of library/*.o based on
270    Git revision and code size measurement tool.
271    """
272
273    def __init__( #pylint: disable=too-many-arguments
274            self,
275            git_rev: str,
276            pre_make_cmd: typing.List[str],
277            make_cmd: str,
278            measure_cmd: str,
279            logger: logging.Logger,
280    ) -> None:
281        """
282        :param git_rev: Git revision. (E.g: commit)
283        :param pre_make_cmd: command to set up proper config before running make.
284        :param make_cmd: command to build library/*.o.
285        :param measure_cmd: command to measure code size for library/*.o.
286        :param logger: logging module
287        """
288        self.repo_path = "."
289        self.git_command = "git"
290        self.make_clean = 'make clean'
291
292        self.git_rev = git_rev
293        self.pre_make_cmd = pre_make_cmd
294        self.make_cmd = make_cmd
295        self.measure_cmd = measure_cmd
296        self.logger = logger
297
298    @staticmethod
299    def validate_git_revision(git_rev: str) -> str:
300        result = subprocess.check_output(["git", "rev-parse", "--verify",
301                                          git_rev + "^{commit}"],
302                                         shell=False, universal_newlines=True)
303        return result[:7]
304
305    def _create_git_worktree(self) -> str:
306        """Create a separate worktree for Git revision.
307        If Git revision is current, use current worktree instead."""
308
309        if self.git_rev == 'current':
310            self.logger.debug("Using current work directory.")
311            git_worktree_path = self.repo_path
312        else:
313            self.logger.debug("Creating git worktree for {}."
314                              .format(self.git_rev))
315            git_worktree_path = os.path.join(self.repo_path,
316                                             "temp-" + self.git_rev)
317            subprocess.check_output(
318                [self.git_command, "worktree", "add", "--detach",
319                 git_worktree_path, self.git_rev], cwd=self.repo_path,
320                stderr=subprocess.STDOUT
321            )
322
323        return git_worktree_path
324
325    @staticmethod
326    def backup_config_files(restore: bool) -> None:
327        """Backup / Restore config files."""
328        if restore:
329            shutil.move(CONFIG_H + BACKUP_SUFFIX, CONFIG_H)
330            shutil.move(CRYPTO_CONFIG_H + BACKUP_SUFFIX, CRYPTO_CONFIG_H)
331        else:
332            shutil.copy(CONFIG_H, CONFIG_H + BACKUP_SUFFIX)
333            shutil.copy(CRYPTO_CONFIG_H, CRYPTO_CONFIG_H + BACKUP_SUFFIX)
334
335    def _build_libraries(self, git_worktree_path: str) -> None:
336        """Build library/*.o in the specified worktree."""
337
338        self.logger.debug("Building library/*.o for {}."
339                          .format(self.git_rev))
340        my_environment = os.environ.copy()
341        try:
342            if self.git_rev == 'current':
343                self.backup_config_files(restore=False)
344            for pre_cmd in self.pre_make_cmd:
345                subprocess.check_output(
346                    pre_cmd, env=my_environment, shell=True,
347                    cwd=git_worktree_path, stderr=subprocess.STDOUT,
348                    universal_newlines=True
349                )
350            subprocess.check_output(
351                self.make_clean, env=my_environment, shell=True,
352                cwd=git_worktree_path, stderr=subprocess.STDOUT,
353                universal_newlines=True
354            )
355            subprocess.check_output(
356                self.make_cmd, env=my_environment, shell=True,
357                cwd=git_worktree_path, stderr=subprocess.STDOUT,
358                universal_newlines=True
359            )
360            if self.git_rev == 'current':
361                self.backup_config_files(restore=True)
362        except subprocess.CalledProcessError as e:
363            self._handle_called_process_error(e, git_worktree_path)
364
365    def _gen_raw_code_size(self, git_worktree_path: str) -> typing.Dict[str, str]:
366        """Measure code size by a tool and return in UTF-8 encoding."""
367
368        self.logger.debug("Measuring code size for {} by `{}`."
369                          .format(self.git_rev,
370                                  self.measure_cmd.strip().split(' ')[0]))
371
372        res = {}
373        for mod, st_lib in MBEDTLS_STATIC_LIB.items():
374            try:
375                result = subprocess.check_output(
376                    [self.measure_cmd + ' ' + st_lib], cwd=git_worktree_path,
377                    shell=True, universal_newlines=True
378                )
379                res[mod] = result
380            except subprocess.CalledProcessError as e:
381                self._handle_called_process_error(e, git_worktree_path)
382
383        return res
384
385    def _remove_worktree(self, git_worktree_path: str) -> None:
386        """Remove temporary worktree."""
387        if git_worktree_path != self.repo_path:
388            self.logger.debug("Removing temporary worktree {}."
389                              .format(git_worktree_path))
390            subprocess.check_output(
391                [self.git_command, "worktree", "remove", "--force",
392                 git_worktree_path], cwd=self.repo_path,
393                stderr=subprocess.STDOUT
394            )
395
396    def _handle_called_process_error(self, e: subprocess.CalledProcessError,
397                                     git_worktree_path: str) -> None:
398        """Handle a CalledProcessError and quit the program gracefully.
399        Remove any extra worktrees so that the script may be called again."""
400
401        # Tell the user what went wrong
402        self.logger.error(e, exc_info=True)
403        self.logger.error("Process output:\n {}".format(e.output))
404
405        # Quit gracefully by removing the existing worktree
406        self._remove_worktree(git_worktree_path)
407        sys.exit(-1)
408
409    def cal_libraries_code_size(self) -> typing.Dict[str, str]:
410        """Do a complete round to calculate code size of library/*.o
411        by measurement tool.
412
413        :return A dictionary of measured code size
414            - typing.Dict[mod: str]
415        """
416
417        git_worktree_path = self._create_git_worktree()
418        try:
419            self._build_libraries(git_worktree_path)
420            res = self._gen_raw_code_size(git_worktree_path)
421        finally:
422            self._remove_worktree(git_worktree_path)
423
424        return res
425
426
427class CodeSizeGenerator:
428    """ A generator based on size measurement tool for library/*.o.
429
430    This is an abstract class. To use it, derive a class that implements
431    write_record and write_comparison methods, then call both of them with
432    proper arguments.
433    """
434    def __init__(self, logger: logging.Logger) -> None:
435        """
436        :param logger: logging module
437        """
438        self.logger = logger
439
440    def write_record(
441            self,
442            git_rev: str,
443            code_size_text: typing.Dict[str, str],
444            output: typing_util.Writable
445    ) -> None:
446        """Write size record into a file.
447
448        :param git_rev: Git revision. (E.g: commit)
449        :param code_size_text:
450            string output (utf-8) from measurement tool of code size.
451                - typing.Dict[mod: str]
452        :param output: output stream which the code size record is written to.
453                       (Note: Normally write code size record into File)
454        """
455        raise NotImplementedError
456
457    def write_comparison( #pylint: disable=too-many-arguments
458            self,
459            old_rev: str,
460            new_rev: str,
461            output: typing_util.Writable,
462            with_markdown=False,
463            show_all=False
464    ) -> None:
465        """Write a comparision result into a stream between two Git revisions.
466
467        :param old_rev: old Git revision to compared with.
468        :param new_rev: new Git revision to compared with.
469        :param output: output stream which the code size record is written to.
470                       (File / sys.stdout)
471        :param with_markdown:  write comparision result in a markdown table.
472                               (Default: False)
473        :param show_all: show all objects in comparison result. (Default False)
474        """
475        raise NotImplementedError
476
477
478class CodeSizeGeneratorWithSize(CodeSizeGenerator):
479    """Code Size Base Class for size record saving and writing."""
480
481    class SizeEntry: # pylint: disable=too-few-public-methods
482        """Data Structure to only store information of code size."""
483        def __init__(self, text: int, data: int, bss: int, dec: int):
484            self.text = text
485            self.data = data
486            self.bss = bss
487            self.total = dec # total <=> dec
488
489    def __init__(self, logger: logging.Logger) -> None:
490        """ Variable code_size is used to store size info for any Git revisions.
491        :param code_size:
492            Data Format as following:
493            code_size = {
494                git_rev: {
495                    module: {
496                        file_name: SizeEntry,
497                        ...
498                    },
499                    ...
500                },
501                ...
502            }
503        """
504        super().__init__(logger)
505        self.code_size = {} #type: typing.Dict[str, typing.Dict]
506        self.mod_total_suffix = '-' + 'TOTALS'
507
508    def _set_size_record(self, git_rev: str, mod: str, size_text: str) -> None:
509        """Store size information for target Git revision and high-level module.
510
511        size_text Format: text data bss dec hex filename
512        """
513        size_record = {}
514        for line in size_text.splitlines()[1:]:
515            data = line.split()
516            if re.match(r'\s*\(TOTALS\)', data[5]):
517                data[5] = mod + self.mod_total_suffix
518            # file_name: SizeEntry(text, data, bss, dec)
519            size_record[data[5]] = CodeSizeGeneratorWithSize.SizeEntry(
520                int(data[0]), int(data[1]), int(data[2]), int(data[3]))
521        self.code_size.setdefault(git_rev, {}).update({mod: size_record})
522
523    def read_size_record(self, git_rev: str, fname: str) -> None:
524        """Read size information from csv file and write it into code_size.
525
526        fname Format: filename text data bss dec
527        """
528        mod = ""
529        size_record = {}
530        with open(fname, 'r') as csv_file:
531            for line in csv_file:
532                data = line.strip().split()
533                # check if we find the beginning of a module
534                if data and data[0] in MBEDTLS_STATIC_LIB:
535                    mod = data[0]
536                    continue
537
538                if mod:
539                    # file_name: SizeEntry(text, data, bss, dec)
540                    size_record[data[0]] = CodeSizeGeneratorWithSize.SizeEntry(
541                        int(data[1]), int(data[2]), int(data[3]), int(data[4]))
542
543                # check if we hit record for the end of a module
544                m = re.match(r'\w+' + self.mod_total_suffix, line)
545                if m:
546                    if git_rev in self.code_size:
547                        self.code_size[git_rev].update({mod: size_record})
548                    else:
549                        self.code_size[git_rev] = {mod: size_record}
550                    mod = ""
551                    size_record = {}
552
553    def write_record(
554            self,
555            git_rev: str,
556            code_size_text: typing.Dict[str, str],
557            output: typing_util.Writable
558    ) -> None:
559        """Write size information to a file.
560
561        Writing Format: filename text data bss total(dec)
562        """
563        for mod, size_text in code_size_text.items():
564            self._set_size_record(git_rev, mod, size_text)
565
566        format_string = "{:<30} {:>7} {:>7} {:>7} {:>7}\n"
567        output.write(format_string.format("filename",
568                                          "text", "data", "bss", "total"))
569
570        for mod, f_size in self.code_size[git_rev].items():
571            output.write("\n" + mod + "\n")
572            for fname, size_entry in f_size.items():
573                output.write(format_string
574                             .format(fname,
575                                     size_entry.text, size_entry.data,
576                                     size_entry.bss, size_entry.total))
577
578    def write_comparison( #pylint: disable=too-many-arguments
579            self,
580            old_rev: str,
581            new_rev: str,
582            output: typing_util.Writable,
583            with_markdown=False,
584            show_all=False
585    ) -> None:
586        # pylint: disable=too-many-locals
587        """Write comparison result into a file.
588
589        Writing Format:
590            Markdown Output:
591                filename new(text) new(data) change(text) change(data)
592            CSV Output:
593                filename new(text) new(data) old(text) old(data) change(text) change(data)
594        """
595        header_line = ["filename", "new(text)", "old(text)", "change(text)",
596                       "new(data)", "old(data)", "change(data)"]
597        if with_markdown:
598            dash_line = [":----", "----:", "----:", "----:",
599                         "----:", "----:", "----:"]
600            # | filename | new(text) | new(data) | change(text) | change(data) |
601            line_format = "| {0:<30} | {1:>9} | {4:>9} | {3:>12} | {6:>12} |\n"
602            bold_text = lambda x: '**' + str(x) + '**'
603        else:
604            # filename new(text) new(data) old(text) old(data) change(text) change(data)
605            line_format = "{0:<30} {1:>9} {4:>9} {2:>10} {5:>10} {3:>12} {6:>12}\n"
606
607        def cal_sect_change(
608                old_size: typing.Optional[CodeSizeGeneratorWithSize.SizeEntry],
609                new_size: typing.Optional[CodeSizeGeneratorWithSize.SizeEntry],
610                sect: str
611        ) -> typing.List:
612            """Inner helper function to calculate size change for a section.
613
614            Convention for special cases:
615                - If the object has been removed in new Git revision,
616                  the size is minus code size of old Git revision;
617                  the size change is marked as `Removed`,
618                - If the object only exists in new Git revision,
619                  the size is code size of new Git revision;
620                  the size change is marked as `None`,
621
622            :param: old_size: code size for objects in old Git revision.
623            :param: new_size: code size for objects in new Git revision.
624            :param: sect: section to calculate from `size` tool. This could be
625                          any instance variable in SizeEntry.
626            :return: List of [section size of objects for new Git revision,
627                     section size of objects for old Git revision,
628                     section size change of objects between two Git revisions]
629            """
630            if old_size and new_size:
631                new_attr = new_size.__dict__[sect]
632                old_attr = old_size.__dict__[sect]
633                delta = new_attr - old_attr
634                change_attr = '{0:{1}}'.format(delta, '+' if delta else '')
635            elif old_size:
636                new_attr = 'Removed'
637                old_attr = old_size.__dict__[sect]
638                delta = - old_attr
639                change_attr = '{0:{1}}'.format(delta, '+' if delta else '')
640            elif new_size:
641                new_attr = new_size.__dict__[sect]
642                old_attr = 'NotCreated'
643                delta = new_attr
644                change_attr = '{0:{1}}'.format(delta, '+' if delta else '')
645            else:
646                # Should never happen
647                new_attr = 'Error'
648                old_attr = 'Error'
649                change_attr = 'Error'
650            return [new_attr, old_attr, change_attr]
651
652        # sort dictionary by key
653        sort_by_k = lambda item: item[0].lower()
654        def get_results(
655                f_rev_size:
656                typing.Dict[str,
657                            typing.Dict[str,
658                                        CodeSizeGeneratorWithSize.SizeEntry]]
659            ) -> typing.List:
660            """Return List of results in the format of:
661            [filename, new(text), old(text), change(text),
662             new(data), old(data), change(data)]
663            """
664            res = []
665            for fname, revs_size in sorted(f_rev_size.items(), key=sort_by_k):
666                old_size = revs_size.get(old_rev)
667                new_size = revs_size.get(new_rev)
668
669                text_sect = cal_sect_change(old_size, new_size, 'text')
670                data_sect = cal_sect_change(old_size, new_size, 'data')
671                # skip the files that haven't changed in code size
672                if not show_all and text_sect[-1] == '0' and data_sect[-1] == '0':
673                    continue
674
675                res.append([fname, *text_sect, *data_sect])
676            return res
677
678        # write header
679        output.write(line_format.format(*header_line))
680        if with_markdown:
681            output.write(line_format.format(*dash_line))
682        for mod in MBEDTLS_STATIC_LIB:
683        # convert self.code_size to:
684        # {
685        #   file_name: {
686        #       old_rev: SizeEntry,
687        #       new_rev: SizeEntry
688        #   },
689        #   ...
690        # }
691            f_rev_size = {} #type: typing.Dict[str, typing.Dict]
692            for fname, size_entry in self.code_size[old_rev][mod].items():
693                f_rev_size.setdefault(fname, {}).update({old_rev: size_entry})
694            for fname, size_entry in self.code_size[new_rev][mod].items():
695                f_rev_size.setdefault(fname, {}).update({new_rev: size_entry})
696
697            mod_total_sz = f_rev_size.pop(mod + self.mod_total_suffix)
698            res = get_results(f_rev_size)
699            total_clm = get_results({mod + self.mod_total_suffix: mod_total_sz})
700            if with_markdown:
701                # bold row of mod-TOTALS in markdown table
702                total_clm = [[bold_text(j) for j in i] for i in total_clm]
703            res += total_clm
704
705            # write comparison result
706            for line in res:
707                output.write(line_format.format(*line))
708
709
710class CodeSizeComparison:
711    """Compare code size between two Git revisions."""
712
713    def __init__( #pylint: disable=too-many-arguments
714            self,
715            old_size_dist_info: CodeSizeDistinctInfo,
716            new_size_dist_info: CodeSizeDistinctInfo,
717            size_common_info: CodeSizeCommonInfo,
718            result_options: CodeSizeResultInfo,
719            logger: logging.Logger,
720    ) -> None:
721        """
722        :param old_size_dist_info: CodeSizeDistinctInfo containing old distinct
723                                   info to compare code size with.
724        :param new_size_dist_info: CodeSizeDistinctInfo containing new distinct
725                                   info to take as comparision base.
726        :param size_common_info: CodeSizeCommonInfo containing common info for
727                                 both old and new size distinct info and
728                                 measurement tool.
729        :param result_options: CodeSizeResultInfo containing results options for
730                               code size record and comparision.
731        :param logger: logging module
732        """
733
734        self.logger = logger
735
736        self.old_size_dist_info = old_size_dist_info
737        self.new_size_dist_info = new_size_dist_info
738        self.size_common_info = size_common_info
739        # infer pre make command
740        self.old_size_dist_info.pre_make_cmd = CodeSizeBuildInfo(
741            self.old_size_dist_info, self.size_common_info.host_arch,
742            self.logger).infer_pre_make_command()
743        self.new_size_dist_info.pre_make_cmd = CodeSizeBuildInfo(
744            self.new_size_dist_info, self.size_common_info.host_arch,
745            self.logger).infer_pre_make_command()
746        # infer make command
747        self.old_size_dist_info.make_cmd = CodeSizeBuildInfo(
748            self.old_size_dist_info, self.size_common_info.host_arch,
749            self.logger).infer_make_command()
750        self.new_size_dist_info.make_cmd = CodeSizeBuildInfo(
751            self.new_size_dist_info, self.size_common_info.host_arch,
752            self.logger).infer_make_command()
753        # initialize size parser with corresponding measurement tool
754        self.code_size_generator = self.__generate_size_parser()
755
756        self.result_options = result_options
757        self.csv_dir = os.path.abspath(self.result_options.record_dir)
758        os.makedirs(self.csv_dir, exist_ok=True)
759        self.comp_dir = os.path.abspath(self.result_options.comp_dir)
760        os.makedirs(self.comp_dir, exist_ok=True)
761
762    def __generate_size_parser(self):
763        """Generate a parser for the corresponding measurement tool."""
764        if re.match(r'size', self.size_common_info.measure_cmd.strip()):
765            return CodeSizeGeneratorWithSize(self.logger)
766        else:
767            self.logger.error("Unsupported measurement tool: `{}`."
768                              .format(self.size_common_info.measure_cmd
769                                      .strip().split(' ')[0]))
770            sys.exit(1)
771
772    def cal_code_size(
773            self,
774            size_dist_info: CodeSizeDistinctInfo
775        ) -> typing.Dict[str, str]:
776        """Calculate code size of library/*.o in a UTF-8 encoding"""
777
778        return CodeSizeCalculator(size_dist_info.git_rev,
779                                  size_dist_info.pre_make_cmd,
780                                  size_dist_info.make_cmd,
781                                  self.size_common_info.measure_cmd,
782                                  self.logger).cal_libraries_code_size()
783
784    def gen_code_size_report(self, size_dist_info: CodeSizeDistinctInfo) -> None:
785        """Generate code size record and write it into a file."""
786
787        self.logger.info("Start to generate code size record for {}."
788                         .format(size_dist_info.git_rev))
789        output_file = os.path.join(
790            self.csv_dir,
791            '{}-{}.csv'
792            .format(size_dist_info.get_info_indication(),
793                    self.size_common_info.get_info_indication()))
794        # Check if the corresponding record exists
795        if size_dist_info.git_rev != "current" and \
796           os.path.exists(output_file):
797            self.logger.debug("Code size csv file for {} already exists."
798                              .format(size_dist_info.git_rev))
799            self.code_size_generator.read_size_record(
800                size_dist_info.git_rev, output_file)
801        else:
802            # measure code size
803            code_size_text = self.cal_code_size(size_dist_info)
804
805            self.logger.debug("Generating code size csv for {}."
806                              .format(size_dist_info.git_rev))
807            output = open(output_file, "w")
808            self.code_size_generator.write_record(
809                size_dist_info.git_rev, code_size_text, output)
810
811    def gen_code_size_comparison(self) -> None:
812        """Generate results of code size changes between two Git revisions,
813        old and new.
814
815        - Measured code size result of these two Git revisions must be available.
816        - The result is directed into either file / stdout depending on
817          the option, size_common_info.result_options.stdout. (Default: file)
818        """
819
820        self.logger.info("Start to generate comparision result between "\
821                         "{} and {}."
822                         .format(self.old_size_dist_info.git_rev,
823                                 self.new_size_dist_info.git_rev))
824        if self.result_options.stdout:
825            output = sys.stdout
826        else:
827            output_file = os.path.join(
828                self.comp_dir,
829                '{}-{}-{}.{}'
830                .format(self.old_size_dist_info.get_info_indication(),
831                        self.new_size_dist_info.get_info_indication(),
832                        self.size_common_info.get_info_indication(),
833                        'md' if self.result_options.with_markdown else 'csv'))
834            output = open(output_file, "w")
835
836        self.logger.debug("Generating comparison results between {} and {}."
837                          .format(self.old_size_dist_info.git_rev,
838                                  self.new_size_dist_info.git_rev))
839        if self.result_options.with_markdown or self.result_options.stdout:
840            print("Measure code size between {} and {} by `{}`."
841                  .format(self.old_size_dist_info.get_info_indication(),
842                          self.new_size_dist_info.get_info_indication(),
843                          self.size_common_info.get_info_indication()),
844                  file=output)
845        self.code_size_generator.write_comparison(
846            self.old_size_dist_info.git_rev,
847            self.new_size_dist_info.git_rev,
848            output, self.result_options.with_markdown,
849            self.result_options.show_all)
850
851    def get_comparision_results(self) -> None:
852        """Compare size of library/*.o between self.old_size_dist_info and
853        self.old_size_dist_info and generate the result file."""
854        build_tree.check_repo_path()
855        self.gen_code_size_report(self.old_size_dist_info)
856        self.gen_code_size_report(self.new_size_dist_info)
857        self.gen_code_size_comparison()
858
859def main():
860    parser = argparse.ArgumentParser(description=(__doc__))
861    group_required = parser.add_argument_group(
862        'required arguments',
863        'required arguments to parse for running ' + os.path.basename(__file__))
864    group_required.add_argument(
865        '-o', '--old-rev', type=str, required=True,
866        help='old Git revision for comparison.')
867
868    group_optional = parser.add_argument_group(
869        'optional arguments',
870        'optional arguments to parse for running ' + os.path.basename(__file__))
871    group_optional.add_argument(
872        '--record-dir', type=str, default='code_size_records',
873        help='directory where code size record is stored. '
874             '(Default: code_size_records)')
875    group_optional.add_argument(
876        '--comp-dir', type=str, default='comparison',
877        help='directory where comparison result is stored. '
878             '(Default: comparison)')
879    group_optional.add_argument(
880        '-n', '--new-rev', type=str, default='current',
881        help='new Git revision as comparison base. '
882             '(Default is the current work directory, including uncommitted '
883             'changes.)')
884    group_optional.add_argument(
885        '-a', '--arch', type=str, default=detect_arch(),
886        choices=list(map(lambda s: s.value, SupportedArch)),
887        help='Specify architecture for code size comparison. '
888             '(Default is the host architecture.)')
889    group_optional.add_argument(
890        '-c', '--config', type=str, default=SupportedConfig.DEFAULT.value,
891        choices=list(map(lambda s: s.value, SupportedConfig)),
892        help='Specify configuration type for code size comparison. '
893             '(Default is the current Mbed TLS configuration.)')
894    group_optional.add_argument(
895        '--markdown', action='store_true', dest='markdown',
896        help='Show comparision of code size in a markdown table. '
897             '(Only show the files that have changed).')
898    group_optional.add_argument(
899        '--stdout', action='store_true', dest='stdout',
900        help='Set this option to direct comparison result into sys.stdout. '
901             '(Default: file)')
902    group_optional.add_argument(
903        '--show-all', action='store_true', dest='show_all',
904        help='Show all the objects in comparison result, including the ones '
905             'that haven\'t changed in code size. (Default: False)')
906    group_optional.add_argument(
907        '--verbose', action='store_true', dest='verbose',
908        help='Show logs in detail for code size measurement. '
909             '(Default: False)')
910    comp_args = parser.parse_args()
911
912    logger = logging.getLogger()
913    logging_util.configure_logger(logger, split_level=logging.NOTSET)
914    logger.setLevel(logging.DEBUG if comp_args.verbose else logging.INFO)
915
916    if os.path.isfile(comp_args.record_dir):
917        logger.error("record directory: {} is not a directory"
918                     .format(comp_args.record_dir))
919        sys.exit(1)
920    if os.path.isfile(comp_args.comp_dir):
921        logger.error("comparison directory: {} is not a directory"
922                     .format(comp_args.comp_dir))
923        sys.exit(1)
924
925    comp_args.old_rev = CodeSizeCalculator.validate_git_revision(
926        comp_args.old_rev)
927    if comp_args.new_rev != 'current':
928        comp_args.new_rev = CodeSizeCalculator.validate_git_revision(
929            comp_args.new_rev)
930
931    # version, git_rev, arch, config, compiler, opt_level
932    old_size_dist_info = CodeSizeDistinctInfo(
933        'old', comp_args.old_rev, comp_args.arch, comp_args.config, 'cc', '-Os')
934    new_size_dist_info = CodeSizeDistinctInfo(
935        'new', comp_args.new_rev, comp_args.arch, comp_args.config, 'cc', '-Os')
936    # host_arch, measure_cmd
937    size_common_info = CodeSizeCommonInfo(
938        detect_arch(), 'size -t')
939    # record_dir, comp_dir, with_markdown, stdout, show_all
940    result_options = CodeSizeResultInfo(
941        comp_args.record_dir, comp_args.comp_dir,
942        comp_args.markdown, comp_args.stdout, comp_args.show_all)
943
944    logger.info("Measure code size between {} and {} by `{}`."
945                .format(old_size_dist_info.get_info_indication(),
946                        new_size_dist_info.get_info_indication(),
947                        size_common_info.get_info_indication()))
948    CodeSizeComparison(old_size_dist_info, new_size_dist_info,
949                       size_common_info, result_options,
950                       logger).get_comparision_results()
951
952if __name__ == "__main__":
953    main()
954