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