Skip to content

Commit a7852cb

Browse files
committed
Python script for parsing eth-gas-reporter output
1 parent 7fc2253 commit a7852cb

File tree

3 files changed

+869
-0
lines changed

3 files changed

+869
-0
lines changed
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
#!/usr/bin/env python3
2+
# coding=utf-8
3+
4+
from dataclasses import asdict, dataclass, field
5+
from typing import Dict, Optional, Tuple
6+
import json
7+
import re
8+
import sys
9+
10+
REPORT_HEADER_REGEX = re.compile(r'''
11+
^[|\s]+ Solc[ ]version:\s*(?P<solc_version>[\w\d.]+)
12+
[|\s]+ Optimizer[ ]enabled:\s*(?P<optimize>[\w]+)
13+
[|\s]+ Runs:\s*(?P<runs>[\d]+)
14+
[|\s]+ Block[ ]limit:\s*(?P<block_limit>[\d]+)\s*gas
15+
[|\s]+$
16+
''', re.VERBOSE)
17+
METHOD_HEADER_REGEX = re.compile(r'^[|\s]+Methods[|\s]+$')
18+
METHOD_COLUMN_HEADERS_REGEX = re.compile(r'''
19+
^[|\s]+ Contract
20+
[|\s]+ Method
21+
[|\s]+ Min
22+
[|\s]+ Max
23+
[|\s]+ Avg
24+
[|\s]+ \#[ ]calls
25+
[|\s]+ \w+[ ]\(avg\)
26+
[|\s]+$
27+
''', re.VERBOSE)
28+
METHOD_ROW_REGEX = re.compile(r'''
29+
^[|\s]+ (?P<contract>[^|]+)
30+
[|\s]+ (?P<method>[^|]+)
31+
[|\s]+ (?P<min>[^|]+)
32+
[|\s]+ (?P<max>[^|]+)
33+
[|\s]+ (?P<avg>[^|]+)
34+
[|\s]+ (?P<call_count>[^|]+)
35+
[|\s]+ (?P<eur_avg>[^|]+)
36+
[|\s]+$
37+
''', re.VERBOSE)
38+
FRAME_REGEX = re.compile(r'^[-|\s]+$')
39+
DEPLOYMENT_HEADER_REGEX = re.compile(r'^[|\s]+Deployments[|\s]+% of limit[|\s]+$')
40+
DEPLOYMENT_ROW_REGEX = re.compile(r'''
41+
^[|\s]+ (?P<contract>[^|]+)
42+
[|\s]+ (?P<min>[^|]+)
43+
[|\s]+ (?P<max>[^|]+)
44+
[|\s]+ (?P<avg>[^|]+)
45+
[|\s]+ (?P<percent_of_limit>[^|]+)\s*%
46+
[|\s]+ (?P<eur_avg>[^|]+)
47+
[|\s]+$
48+
''', re.VERBOSE)
49+
50+
51+
class ReportError(Exception):
52+
pass
53+
54+
class ReportValidationError(ReportError):
55+
pass
56+
57+
class ReportParsingError(Exception):
58+
def __init__(self, message: str, line: str, line_number: int):
59+
# pylint: disable=useless-super-delegation # It's not useless, it adds type annotations.
60+
super().__init__(message, line, line_number)
61+
62+
def __str__(self):
63+
return f"Parsing error on line {self.args[2] + 1}: {self.args[0]}\n{self.args[1]}"
64+
65+
66+
@dataclass(frozen=True)
67+
class MethodGasReport:
68+
min_gas: int
69+
max_gas: int
70+
avg_gas: int
71+
call_count: int
72+
total_gas: int = field(init=False)
73+
74+
def __post_init__(self):
75+
object.__setattr__(self, 'total_gas', self.avg_gas * self.call_count)
76+
77+
78+
@dataclass(frozen=True)
79+
class ContractGasReport:
80+
min_deployment_gas: Optional[int]
81+
max_deployment_gas: Optional[int]
82+
avg_deployment_gas: Optional[int]
83+
methods: Optional[Dict[str, MethodGasReport]]
84+
total_method_gas: int = field(init=False, default=0)
85+
86+
def __post_init__(self):
87+
if self.methods is not None:
88+
object.__setattr__(self, 'total_method_gas', sum(method.total_gas for method in self.methods.values()))
89+
90+
91+
@dataclass(frozen=True)
92+
class GasReport:
93+
solc_version: str
94+
optimize: bool
95+
runs: int
96+
block_limit: int
97+
contracts: Dict[str, ContractGasReport]
98+
total_method_gas: int = field(init=False)
99+
total_deployment_gas: int = field(init=False)
100+
101+
def __post_init__(self):
102+
object.__setattr__(self, 'total_method_gas', sum(
103+
total_method_gas
104+
for total_method_gas in (contract.total_method_gas for contract in self.contracts.values())
105+
if total_method_gas is not None
106+
))
107+
object.__setattr__(self, 'total_deployment_gas', sum(
108+
contract.avg_deployment_gas
109+
for contract in self.contracts.values()
110+
if contract.avg_deployment_gas is not None
111+
))
112+
113+
def to_json(self):
114+
return json.dumps(asdict(self), indent=4, sort_keys=True)
115+
116+
117+
def parse_bool(input_string: str) -> bool:
118+
if input_string == 'true':
119+
return True
120+
elif input_string == 'false':
121+
return True
122+
else:
123+
raise ValueError(f"Invalid boolean value: '{input_string}'")
124+
125+
126+
def parse_optional_int(input_string: str, default: Optional[int] = None) -> Optional[int]:
127+
if input_string.strip() == '-':
128+
return default
129+
130+
return int(input_string)
131+
132+
133+
def parse_report_header(line: str) -> Optional[dict]:
134+
match = REPORT_HEADER_REGEX.match(line)
135+
if match is None:
136+
return None
137+
138+
return {
139+
'solc_version': match.group('solc_version'),
140+
'optimize': parse_bool(match.group('optimize')),
141+
'runs': int(match.group('runs')),
142+
'block_limit': int(match.group('block_limit')),
143+
}
144+
145+
146+
def parse_method_row(line: str, line_number: int) -> Optional[Tuple[str, str, MethodGasReport]]:
147+
match = METHOD_ROW_REGEX.match(line)
148+
if match is None:
149+
raise ReportParsingError("Expected a table row with method details.", line, line_number)
150+
151+
avg_gas = parse_optional_int(match['avg'])
152+
call_count = int(match['call_count'])
153+
154+
if avg_gas is None and call_count == 0:
155+
# No calls, no gas values. Uninteresting. Skip the row.
156+
return None
157+
158+
return (
159+
match['contract'].strip(),
160+
match['method'].strip(),
161+
MethodGasReport(
162+
min_gas=parse_optional_int(match['min'], avg_gas),
163+
max_gas=parse_optional_int(match['max'], avg_gas),
164+
avg_gas=avg_gas,
165+
call_count=call_count,
166+
)
167+
)
168+
169+
170+
def parse_deployment_row(line: str, line_number: int) -> Tuple[str, int, int, int]:
171+
match = DEPLOYMENT_ROW_REGEX.match(line)
172+
if match is None:
173+
raise ReportParsingError("Expected a table row with deployment details.", line, line_number)
174+
175+
return (
176+
match['contract'].strip(),
177+
parse_optional_int(match['min'].strip()),
178+
parse_optional_int(match['max'].strip()),
179+
int(match['avg'].strip()),
180+
)
181+
182+
183+
def preprocess_unicode_frames(input_string: str) -> str:
184+
# The report has a mix of normal pipe chars and its unicode variant.
185+
# Let's just replace all frame chars with normal pipes for easier parsing.
186+
return input_string.replace('\u2502', '|').replace('·', '|')
187+
188+
189+
def parse_report(rst_report: str) -> GasReport:
190+
report_params = None
191+
methods_by_contract = {}
192+
deployment_costs = {}
193+
expected_row_type = None
194+
195+
for line_number, line in enumerate(preprocess_unicode_frames(rst_report).splitlines()):
196+
try:
197+
if (
198+
line.strip() == "" or
199+
FRAME_REGEX.match(line) is not None or
200+
METHOD_COLUMN_HEADERS_REGEX.match(line) is not None
201+
):
202+
continue
203+
if METHOD_HEADER_REGEX.match(line) is not None:
204+
expected_row_type = 'method'
205+
continue
206+
if DEPLOYMENT_HEADER_REGEX.match(line) is not None:
207+
expected_row_type = 'deployment'
208+
continue
209+
210+
new_report_params = parse_report_header(line)
211+
if new_report_params is not None:
212+
if report_params is not None:
213+
raise ReportParsingError("Duplicate report header.", line, line_number)
214+
215+
report_params = new_report_params
216+
continue
217+
218+
if expected_row_type == 'method':
219+
parsed_row = parse_method_row(line, line_number)
220+
if parsed_row is None:
221+
continue
222+
223+
(contract, method, method_report) = parsed_row
224+
225+
if contract not in methods_by_contract:
226+
methods_by_contract[contract] = {}
227+
228+
if method in methods_by_contract[contract]:
229+
# Report must be generated with full signatures for method names to be unambiguous.
230+
raise ReportParsingError(f"Duplicate method row for '{contract}.{method}'.", line, line_number)
231+
232+
methods_by_contract[contract][method] = method_report
233+
elif expected_row_type == 'deployment':
234+
(contract, min_gas, max_gas, avg_gas) = parse_deployment_row(line, line_number)
235+
236+
if contract in deployment_costs:
237+
raise ReportParsingError(f"Duplicate contract deployment row for '{contract}'.", line, line_number)
238+
239+
deployment_costs[contract] = (min_gas, max_gas, avg_gas)
240+
else:
241+
assert expected_row_type is None
242+
raise ReportParsingError("Found data row without a section header.", line, line_number)
243+
244+
except ValueError as error:
245+
raise ReportParsingError(error.args[0], line, line_number) from error
246+
247+
if report_params is None:
248+
raise ReportValidationError("Report header not found.")
249+
250+
report_params['contracts'] = {
251+
contract: ContractGasReport(
252+
min_deployment_gas=deployment_costs.get(contract, (None, None, None))[0],
253+
max_deployment_gas=deployment_costs.get(contract, (None, None, None))[1],
254+
avg_deployment_gas=deployment_costs.get(contract, (None, None, None))[2],
255+
methods=methods_by_contract.get(contract),
256+
)
257+
for contract in methods_by_contract.keys() | deployment_costs.keys()
258+
}
259+
260+
return GasReport(**report_params)
261+
262+
263+
if __name__ == "__main__":
264+
try:
265+
report = parse_report(sys.stdin.read())
266+
print(report.to_json())
267+
except ReportError as exception:
268+
print(f"{exception}", file=sys.stderr)
269+
sys.exit(1)

0 commit comments

Comments
 (0)