mirror of
				https://github.com/ethereum/solidity
				synced 2023-10-03 13:03:40 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			454 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
	
	
	
			
		
		
	
	
			454 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Python
		
	
	
		
			Executable File
		
	
	
	
	
#!/usr/bin/env python3
 | 
						|
 | 
						|
from argparse import ArgumentParser
 | 
						|
from dataclasses import dataclass
 | 
						|
from enum import Enum
 | 
						|
from pathlib import Path
 | 
						|
from textwrap import dedent
 | 
						|
from typing import Any, Mapping, Optional, Set, Sequence, Union
 | 
						|
import json
 | 
						|
import sys
 | 
						|
 | 
						|
 | 
						|
class DiffMode(Enum):
 | 
						|
    IN_PLACE = 'inplace'
 | 
						|
    TABLE = 'table'
 | 
						|
 | 
						|
 | 
						|
class DifferenceStyle(Enum):
 | 
						|
    ABSOLUTE = 'absolute'
 | 
						|
    RELATIVE = 'relative'
 | 
						|
    HUMANIZED = 'humanized'
 | 
						|
 | 
						|
 | 
						|
class OutputFormat(Enum):
 | 
						|
    JSON = 'json'
 | 
						|
    CONSOLE = 'console'
 | 
						|
    MARKDOWN = 'markdown'
 | 
						|
 | 
						|
 | 
						|
DEFAULT_RELATIVE_PRECISION = 4
 | 
						|
 | 
						|
DEFAULT_DIFFERENCE_STYLE = {
 | 
						|
    DiffMode.IN_PLACE: DifferenceStyle.ABSOLUTE,
 | 
						|
    DiffMode.TABLE: DifferenceStyle.HUMANIZED,
 | 
						|
}
 | 
						|
assert all(t in DiffMode for t in DEFAULT_DIFFERENCE_STYLE)
 | 
						|
assert all(d in DifferenceStyle for d in DEFAULT_DIFFERENCE_STYLE.values())
 | 
						|
 | 
						|
DEFAULT_OUTPUT_FORMAT = {
 | 
						|
    DiffMode.IN_PLACE: OutputFormat.JSON,
 | 
						|
    DiffMode.TABLE: OutputFormat.CONSOLE,
 | 
						|
}
 | 
						|
assert all(m in DiffMode for m in DEFAULT_OUTPUT_FORMAT)
 | 
						|
assert all(o in OutputFormat for o in DEFAULT_OUTPUT_FORMAT.values())
 | 
						|
 | 
						|
 | 
						|
class ValidationError(Exception):
 | 
						|
    pass
 | 
						|
 | 
						|
 | 
						|
class CommandLineError(ValidationError):
 | 
						|
    pass
 | 
						|
 | 
						|
 | 
						|
class BenchmarkDiffer:
 | 
						|
    difference_style: DifferenceStyle
 | 
						|
    relative_precision: Optional[int]
 | 
						|
    output_format: OutputFormat
 | 
						|
 | 
						|
    def __init__(
 | 
						|
        self,
 | 
						|
        difference_style: DifferenceStyle,
 | 
						|
        relative_precision: Optional[int],
 | 
						|
        output_format: OutputFormat,
 | 
						|
    ):
 | 
						|
        self.difference_style = difference_style
 | 
						|
        self.relative_precision = relative_precision
 | 
						|
        self.output_format = output_format
 | 
						|
 | 
						|
    def run(self, before: Any, after: Any) -> Optional[Union[dict, str, int, float]]:
 | 
						|
        if not isinstance(before, dict) or not isinstance(after, dict):
 | 
						|
            return self._diff_scalars(before, after)
 | 
						|
 | 
						|
        if before.get('version') != after.get('version'):
 | 
						|
            return self._humanize_diff('!V')
 | 
						|
 | 
						|
        diff = {}
 | 
						|
        for key in (set(before) | set(after)) - {'version'}:
 | 
						|
            value_diff = self.run(before.get(key), after.get(key))
 | 
						|
            if value_diff not in [None, {}]:
 | 
						|
                diff[key] = value_diff
 | 
						|
 | 
						|
        return diff
 | 
						|
 | 
						|
    def _diff_scalars(self, before: Any, after: Any) -> Optional[Union[str, int, float, dict]]:
 | 
						|
        assert not isinstance(before, dict) or not isinstance(after, dict)
 | 
						|
 | 
						|
        if before is None and after is None:
 | 
						|
            return {}
 | 
						|
        if before is None:
 | 
						|
            return self._humanize_diff('!B')
 | 
						|
        if after is None:
 | 
						|
            return self._humanize_diff('!A')
 | 
						|
        if not isinstance(before, (int, float)) or not isinstance(after, (int, float)):
 | 
						|
            return self._humanize_diff('!T')
 | 
						|
 | 
						|
        number_diff = self._diff_numbers(before, after)
 | 
						|
        if self.difference_style != DifferenceStyle.HUMANIZED:
 | 
						|
            return number_diff
 | 
						|
 | 
						|
        return self._humanize_diff(number_diff)
 | 
						|
 | 
						|
    def _diff_numbers(self, value_before: Union[int, float], value_after: Union[int, float]) -> Union[str, int, float]:
 | 
						|
        diff: Union[str, int, float]
 | 
						|
 | 
						|
        if self.difference_style == DifferenceStyle.ABSOLUTE:
 | 
						|
            diff = value_after - value_before
 | 
						|
            if isinstance(diff, float) and diff.is_integer():
 | 
						|
                diff = int(diff)
 | 
						|
 | 
						|
            return diff
 | 
						|
 | 
						|
        if value_before == 0:
 | 
						|
            if value_after > 0:
 | 
						|
                return '+INF'
 | 
						|
            elif value_after < 0:
 | 
						|
                return '-INF'
 | 
						|
            else:
 | 
						|
                return 0
 | 
						|
 | 
						|
        diff = (value_after - value_before) / abs(value_before)
 | 
						|
        if self.relative_precision is not None:
 | 
						|
            rounded_diff = round(diff, self.relative_precision)
 | 
						|
            if rounded_diff == 0 and diff < 0:
 | 
						|
                diff = '-0'
 | 
						|
            elif rounded_diff == 0 and diff > 0:
 | 
						|
                diff = '+0'
 | 
						|
            else:
 | 
						|
                diff = rounded_diff
 | 
						|
 | 
						|
        if isinstance(diff, float) and diff.is_integer():
 | 
						|
            diff = int(diff)
 | 
						|
 | 
						|
        return diff
 | 
						|
 | 
						|
    def _humanize_diff(self, diff: Union[str, int, float]) -> str:
 | 
						|
        def wrap(value: str, symbol: str):
 | 
						|
            return f"{symbol}{value}{symbol}"
 | 
						|
 | 
						|
        markdown = (self.output_format == OutputFormat.MARKDOWN)
 | 
						|
 | 
						|
        if isinstance(diff, str) and diff.startswith('!'):
 | 
						|
            return wrap(diff, '`' if markdown else '')
 | 
						|
 | 
						|
        value: Union[str, int, float]
 | 
						|
        if isinstance(diff, (int, float)):
 | 
						|
            value = diff * 100
 | 
						|
            if isinstance(value, float) and self.relative_precision is not None:
 | 
						|
                # The multiplication can result in new significant digits appearing. We need to reround.
 | 
						|
                # NOTE: round() works fine with negative precision.
 | 
						|
                value = round(value, self.relative_precision - 2)
 | 
						|
                if isinstance(value, float) and value.is_integer():
 | 
						|
                    value = int(value)
 | 
						|
            suffix = ''
 | 
						|
            prefix = ''
 | 
						|
            if diff < 0:
 | 
						|
                prefix = ''
 | 
						|
                if markdown:
 | 
						|
                    suffix += ' ✅'
 | 
						|
            elif diff > 0:
 | 
						|
                prefix = '+'
 | 
						|
                if markdown:
 | 
						|
                    suffix += ' ❌'
 | 
						|
            important = (diff != 0)
 | 
						|
        else:
 | 
						|
            value = diff
 | 
						|
            important = False
 | 
						|
            prefix = ''
 | 
						|
            suffix = ''
 | 
						|
 | 
						|
        return wrap(
 | 
						|
            wrap(
 | 
						|
                f"{prefix}{value}%{suffix}",
 | 
						|
                '`' if markdown else ''
 | 
						|
            ),
 | 
						|
            '**' if important and markdown else ''
 | 
						|
        )
 | 
						|
 | 
						|
 | 
						|
@dataclass(frozen=True)
 | 
						|
class DiffTable:
 | 
						|
    columns: Mapping[str, Sequence[Union[int, float, str]]]
 | 
						|
 | 
						|
 | 
						|
class DiffTableSet:
 | 
						|
    table_headers: Sequence[str]
 | 
						|
    row_headers: Sequence[str]
 | 
						|
    column_headers: Sequence[str]
 | 
						|
 | 
						|
    # Cells is a nested dict rather than a 3D array so that conversion to JSON is straightforward
 | 
						|
    cells: Mapping[str, Mapping[str, Mapping[str, Union[int, float, str]]]] # preset -> project -> attribute
 | 
						|
 | 
						|
    def __init__(self, diff: dict):
 | 
						|
        self.table_headers = sorted(self._find_all_preset_names(diff))
 | 
						|
        self.column_headers = sorted(self._find_all_attribute_names(diff))
 | 
						|
        self.row_headers = sorted(project for project in diff)
 | 
						|
 | 
						|
        # All dimensions must have unique values
 | 
						|
        assert len(self.table_headers) == len(set(self.table_headers))
 | 
						|
        assert len(self.column_headers) == len(set(self.column_headers))
 | 
						|
        assert len(self.row_headers) == len(set(self.row_headers))
 | 
						|
 | 
						|
        self.cells = {
 | 
						|
            preset: {
 | 
						|
                project: {
 | 
						|
                    attribute: self._cell_content(diff, project, preset, attribute)
 | 
						|
                    for attribute in self.column_headers
 | 
						|
                }
 | 
						|
                for project in self.row_headers
 | 
						|
            }
 | 
						|
            for preset in self.table_headers
 | 
						|
        }
 | 
						|
 | 
						|
    def calculate_row_column_width(self) -> int:
 | 
						|
        return max(len(h) for h in self.row_headers)
 | 
						|
 | 
						|
    def calculate_column_widths(self, table_header: str) -> Sequence[int]:
 | 
						|
        assert table_header in self.table_headers
 | 
						|
 | 
						|
        return [
 | 
						|
            max(
 | 
						|
                len(column_header),
 | 
						|
                max(
 | 
						|
                    len(str(self.cells[table_header][row_header][column_header]))
 | 
						|
                    for row_header in self.row_headers
 | 
						|
                )
 | 
						|
            )
 | 
						|
            for column_header in self.column_headers
 | 
						|
        ]
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def _find_all_preset_names(cls, diff: dict) -> Set[str]:
 | 
						|
        return {
 | 
						|
            preset
 | 
						|
            for project, project_diff in diff.items()
 | 
						|
            if isinstance(project_diff, dict)
 | 
						|
            for preset in project_diff
 | 
						|
        }
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def _find_all_attribute_names(cls, diff: dict) -> Set[str]:
 | 
						|
        return {
 | 
						|
            attribute
 | 
						|
            for project, project_diff in diff.items()
 | 
						|
            if isinstance(project_diff, dict)
 | 
						|
            for preset, preset_diff in project_diff.items()
 | 
						|
            if isinstance(preset_diff, dict)
 | 
						|
            for attribute in preset_diff
 | 
						|
        }
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def _cell_content(cls, diff: dict, project: str, preset: str, attribute: str) -> str:
 | 
						|
        assert project in diff
 | 
						|
 | 
						|
        if isinstance(diff[project], str):
 | 
						|
            return diff[project]
 | 
						|
        if preset not in diff[project]:
 | 
						|
            return ''
 | 
						|
        if isinstance(diff[project][preset], str):
 | 
						|
            return diff[project][preset]
 | 
						|
        if attribute not in diff[project][preset]:
 | 
						|
            return ''
 | 
						|
 | 
						|
        return diff[project][preset][attribute]
 | 
						|
 | 
						|
 | 
						|
class DiffTableFormatter:
 | 
						|
    LEGEND = dedent("""
 | 
						|
        `!V` = version mismatch
 | 
						|
        `!B` = no value in the "before" version
 | 
						|
        `!A` = no value in the "after" version
 | 
						|
        `!T` = one or both values were not numeric and could not be compared
 | 
						|
        `-0` = very small negative value rounded to zero
 | 
						|
        `+0` = very small positive value rounded to zero
 | 
						|
    """)
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def run(cls, diff_table_set: DiffTableSet, output_format: OutputFormat):
 | 
						|
        if output_format == OutputFormat.JSON:
 | 
						|
            return json.dumps(diff_table_set.cells, indent=4, sort_keys=True)
 | 
						|
        else:
 | 
						|
            assert output_format in {OutputFormat.CONSOLE, OutputFormat.MARKDOWN}
 | 
						|
 | 
						|
            output = ''
 | 
						|
            for table_header in diff_table_set.table_headers:
 | 
						|
                column_widths = ([
 | 
						|
                    diff_table_set.calculate_row_column_width(),
 | 
						|
                    *diff_table_set.calculate_column_widths(table_header)
 | 
						|
                ])
 | 
						|
 | 
						|
                if output_format == OutputFormat.MARKDOWN:
 | 
						|
                    output += f'\n### `{table_header}`\n'
 | 
						|
                else:
 | 
						|
                    output += f'\n{table_header.upper()}\n'
 | 
						|
 | 
						|
                if output_format == OutputFormat.CONSOLE:
 | 
						|
                    output += cls._format_separator_row(column_widths, output_format) + '\n'
 | 
						|
                output += cls._format_data_row(['project', *diff_table_set.column_headers], column_widths) + '\n'
 | 
						|
                output += cls._format_separator_row(column_widths, output_format) + '\n'
 | 
						|
 | 
						|
                for row_header in diff_table_set.row_headers:
 | 
						|
                    row = [
 | 
						|
                        diff_table_set.cells[table_header][row_header][column_header]
 | 
						|
                        for column_header in diff_table_set.column_headers
 | 
						|
                    ]
 | 
						|
                    output += cls._format_data_row([row_header, *row], column_widths) + '\n'
 | 
						|
 | 
						|
                if output_format == OutputFormat.CONSOLE:
 | 
						|
                    output += cls._format_separator_row(column_widths, output_format) + '\n'
 | 
						|
 | 
						|
            if output_format == OutputFormat.MARKDOWN:
 | 
						|
                output += f'\n{cls.LEGEND}\n'
 | 
						|
            return output
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def _format_separator_row(cls, widths: Sequence[int], output_format: OutputFormat):
 | 
						|
        assert output_format in {OutputFormat.CONSOLE, OutputFormat.MARKDOWN}
 | 
						|
 | 
						|
        if output_format == OutputFormat.MARKDOWN:
 | 
						|
            return '|:' + ':|-'.join('-' * width for width in widths) + ':|'
 | 
						|
        else:
 | 
						|
            return '|-' + '-|-'.join('-' * width for width in widths) + '-|'
 | 
						|
 | 
						|
    @classmethod
 | 
						|
    def _format_data_row(cls, cells: Sequence[Union[int, float, str]], widths: Sequence[int]):
 | 
						|
        assert len(cells) == len(widths)
 | 
						|
 | 
						|
        return '| ' + ' | '.join(str(cell).rjust(width) for cell, width in zip(cells, widths)) + ' |'
 | 
						|
 | 
						|
 | 
						|
@dataclass(frozen=True)
 | 
						|
class CommandLineOptions:
 | 
						|
    diff_mode: DiffMode
 | 
						|
    report_before: Path
 | 
						|
    report_after: Path
 | 
						|
    difference_style: DifferenceStyle
 | 
						|
    relative_precision: int
 | 
						|
    output_format: OutputFormat
 | 
						|
 | 
						|
 | 
						|
def process_commandline() -> CommandLineOptions:
 | 
						|
    script_description = (
 | 
						|
        "Compares summarized benchmark reports and outputs JSON with the same structure but listing only differences. "
 | 
						|
        "Can also print the output as markdown table and format the values to make differences stand out more."
 | 
						|
    )
 | 
						|
 | 
						|
    parser = ArgumentParser(description=script_description)
 | 
						|
    parser.add_argument(
 | 
						|
        dest='diff_mode',
 | 
						|
        choices=[m.value for m in DiffMode],
 | 
						|
        help=(
 | 
						|
            "Diff mode: "
 | 
						|
            f"'{DiffMode.IN_PLACE.value}' preserves input JSON structure and replace values with differences; "
 | 
						|
            f"'{DiffMode.TABLE.value}' creates a table assuming 3-level project/preset/attribute structure."
 | 
						|
        )
 | 
						|
    )
 | 
						|
    parser.add_argument(dest='report_before', help="Path to a JSON file containing original benchmark results.")
 | 
						|
    parser.add_argument(dest='report_after', help="Path to a JSON file containing new benchmark results.")
 | 
						|
    parser.add_argument(
 | 
						|
        '--style',
 | 
						|
        dest='difference_style',
 | 
						|
        choices=[s.value for s in DifferenceStyle],
 | 
						|
        help=(
 | 
						|
            "How to present numeric differences: "
 | 
						|
            f"'{DifferenceStyle.ABSOLUTE.value}' subtracts new from original; "
 | 
						|
            f"'{DifferenceStyle.RELATIVE.value}' also divides by the original; "
 | 
						|
            f"'{DifferenceStyle.HUMANIZED.value}' is like relative but value is a percentage and "
 | 
						|
            "positive/negative changes are emphasized. "
 | 
						|
            f"(default: '{DEFAULT_DIFFERENCE_STYLE[DiffMode.IN_PLACE]}' in '{DiffMode.IN_PLACE.value}' mode, "
 | 
						|
            f"'{DEFAULT_DIFFERENCE_STYLE[DiffMode.TABLE]}' in '{DiffMode.TABLE.value}' mode)"
 | 
						|
        )
 | 
						|
    )
 | 
						|
    # NOTE: Negative values are valid for precision. round() handles them in a sensible way.
 | 
						|
    parser.add_argument(
 | 
						|
        '--precision',
 | 
						|
        dest='relative_precision',
 | 
						|
        type=int,
 | 
						|
        default=DEFAULT_RELATIVE_PRECISION,
 | 
						|
        help=(
 | 
						|
            "Number of significant digits for relative differences. "
 | 
						|
            f"Note that with --style={DifferenceStyle.HUMANIZED.value} the rounding is applied "
 | 
						|
            "**before** converting the value to a percentage so you need to add 2. "
 | 
						|
            f"Has no effect when used together with --style={DifferenceStyle.ABSOLUTE.value}. "
 | 
						|
            f"(default: {DEFAULT_RELATIVE_PRECISION})"
 | 
						|
        )
 | 
						|
    )
 | 
						|
    parser.add_argument(
 | 
						|
        '--output-format',
 | 
						|
        dest='output_format',
 | 
						|
        choices=[o.value for o in OutputFormat],
 | 
						|
        help=(
 | 
						|
            "The format to use for the diff: "
 | 
						|
            f"'{OutputFormat.JSON.value}' is raw JSON; "
 | 
						|
            f"'{OutputFormat.CONSOLE.value}' is a table with human-readable values that will look good in the console output. "
 | 
						|
            f"'{OutputFormat.MARKDOWN.value}' is similar '{OutputFormat.CONSOLE.value}' but adjusted to "
 | 
						|
            "render as proper markdown and with extra elements (legend, emoji to make non-zero values stand out more, etc)."
 | 
						|
            f"(default: '{DEFAULT_OUTPUT_FORMAT[DiffMode.IN_PLACE]}' in '{DiffMode.IN_PLACE.value}' mode, "
 | 
						|
            f"'{DEFAULT_OUTPUT_FORMAT[DiffMode.TABLE]}' in '{DiffMode.TABLE.value}' mode)"
 | 
						|
        )
 | 
						|
    )
 | 
						|
 | 
						|
    options = parser.parse_args()
 | 
						|
 | 
						|
    if options.difference_style is not None:
 | 
						|
        difference_style = DifferenceStyle(options.difference_style)
 | 
						|
    else:
 | 
						|
        difference_style = DEFAULT_DIFFERENCE_STYLE[DiffMode(options.diff_mode)]
 | 
						|
 | 
						|
    if options.output_format is not None:
 | 
						|
        output_format = OutputFormat(options.output_format)
 | 
						|
    else:
 | 
						|
        output_format = DEFAULT_OUTPUT_FORMAT[DiffMode(options.diff_mode)]
 | 
						|
 | 
						|
    processed_options = CommandLineOptions(
 | 
						|
        diff_mode=DiffMode(options.diff_mode),
 | 
						|
        report_before=Path(options.report_before),
 | 
						|
        report_after=Path(options.report_after),
 | 
						|
        difference_style=difference_style,
 | 
						|
        relative_precision=options.relative_precision,
 | 
						|
        output_format=output_format,
 | 
						|
    )
 | 
						|
 | 
						|
    if processed_options.diff_mode == DiffMode.IN_PLACE and processed_options.output_format != OutputFormat.JSON:
 | 
						|
        raise CommandLineError(
 | 
						|
            f"Only the '{OutputFormat.JSON.value}' output format is supported in the '{DiffMode.IN_PLACE.value}' mode."
 | 
						|
        )
 | 
						|
 | 
						|
    return processed_options
 | 
						|
 | 
						|
 | 
						|
def main():
 | 
						|
    try:
 | 
						|
        options = process_commandline()
 | 
						|
 | 
						|
        differ = BenchmarkDiffer(options.difference_style, options.relative_precision, options.output_format)
 | 
						|
        diff = differ.run(
 | 
						|
            json.loads(options.report_before.read_text('utf-8')),
 | 
						|
            json.loads(options.report_after.read_text('utf-8')),
 | 
						|
        )
 | 
						|
 | 
						|
        if options.diff_mode == DiffMode.IN_PLACE:
 | 
						|
            print(json.dumps(diff, indent=4, sort_keys=True))
 | 
						|
        else:
 | 
						|
            assert options.diff_mode == DiffMode.TABLE
 | 
						|
            print(DiffTableFormatter.run(DiffTableSet(diff), options.output_format))
 | 
						|
 | 
						|
        return 0
 | 
						|
    except CommandLineError as exception:
 | 
						|
        print(f"ERROR: {exception}", file=sys.stderr)
 | 
						|
        return 1
 | 
						|
 | 
						|
if __name__ == "__main__":
 | 
						|
    sys.exit(main())
 |