Skip to content

Commit 8056cda

Browse files
authored
Api Diff Report Tools (#11184)
1 parent 4bec33f commit 8056cda

File tree

11 files changed

+962
-0
lines changed

11 files changed

+962
-0
lines changed
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
# Copyright 2023 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the 'License');
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an 'AS IS' BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import json
16+
import argparse
17+
import logging
18+
import os
19+
import api_info
20+
import datetime
21+
import pytz
22+
23+
STATUS_ADD = 'ADDED'
24+
STATUS_REMOVED = 'REMOVED'
25+
STATUS_MODIFIED = 'MODIFIED'
26+
STATUS_ERROR = 'BUILD ERROR'
27+
28+
29+
def main():
30+
logging.getLogger().setLevel(logging.INFO)
31+
32+
args = parse_cmdline_args()
33+
34+
pr_branch = os.path.expanduser(args.pr_branch)
35+
base_branch = os.path.expanduser(args.base_branch)
36+
new_api_json = json.load(
37+
open(os.path.join(pr_branch, api_info.API_INFO_FILE_NAME)))
38+
old_api_json = json.load(
39+
open(os.path.join(base_branch, api_info.API_INFO_FILE_NAME)))
40+
41+
diff = generate_diff_json(new_api_json, old_api_json)
42+
if diff:
43+
logging.info(f'json diff: \n{json.dumps(diff, indent=2)}')
44+
logging.info(f'plain text diff report: \n{generate_text_report(diff)}')
45+
logging.info(
46+
f'markdown diff report title: \n{generate_markdown_title(args.commit, args.run_id)}'
47+
)
48+
logging.info(
49+
f'markdown diff report: \n{generate_markdown_report(diff)}')
50+
else:
51+
logging.info('No API Diff Detected.')
52+
53+
54+
def generate_diff_json(new_api, old_api, level='module'):
55+
"""diff_json only contains module & api that has a change.
56+
57+
format:
58+
{
59+
$(module_name_1): {
60+
"api_types": {
61+
$(api_type_1): {
62+
"apis": {
63+
$(api_1): {
64+
"declaration": [
65+
$(api_1_declaration)
66+
],
67+
"sub_apis": {
68+
$(sub_api_1): {
69+
"declaration": [
70+
$(sub_api_1_declaration)
71+
]
72+
},
73+
},
74+
"status": $(diff_status)
75+
}
76+
}
77+
}
78+
}
79+
}
80+
}
81+
"""
82+
NEXT_LEVEL = {
83+
'module': 'api_types',
84+
'api_types': 'apis',
85+
'apis': 'sub_apis'
86+
}
87+
next_level = NEXT_LEVEL.get(level)
88+
89+
diff = {}
90+
for key in set(new_api.keys()).union(old_api.keys()):
91+
# Added API
92+
if key not in old_api:
93+
diff[key] = new_api[key]
94+
diff[key]['status'] = STATUS_ADD
95+
if diff[key].get('declaration'):
96+
diff[key]['declaration'] = [STATUS_ADD
97+
] + diff[key]['declaration']
98+
# Removed API
99+
elif key not in new_api:
100+
diff[key] = old_api[key]
101+
diff[key]['status'] = STATUS_REMOVED
102+
if diff[key].get('declaration'):
103+
diff[key]['declaration'] = [STATUS_REMOVED
104+
] + diff[key]['declaration']
105+
# Moudle Build Error. If a "module" exist but have no
106+
# content (e.g. doc_path), it must have a build error.
107+
elif level == 'module' and (not new_api[key]['path']
108+
or not old_api[key]['path']):
109+
diff[key] = {'status': STATUS_ERROR}
110+
# Check diff in clild level and diff in declaration
111+
else:
112+
child_diff = generate_diff_json(
113+
new_api[key][next_level],
114+
old_api[key][next_level],
115+
level=next_level) if next_level else {}
116+
declaration_diff = new_api[key].get(
117+
'declaration') != old_api[key].get('declaration') if level in [
118+
'apis', 'sub_apis'
119+
] else False
120+
121+
# No diff
122+
if not child_diff and not declaration_diff:
123+
continue
124+
125+
diff[key] = new_api[key]
126+
# Changes at child level
127+
if child_diff:
128+
diff[key][next_level] = child_diff
129+
130+
# Modified API (changes in API declaration)
131+
if declaration_diff:
132+
diff[key]['status'] = STATUS_MODIFIED
133+
diff[key]['declaration'] = [STATUS_ADD] + new_api[key]['declaration'] + \
134+
[STATUS_REMOVED] + old_api[key]['declaration']
135+
136+
return diff
137+
138+
139+
def generate_text_report(diff, level=0, print_key=True):
140+
report = ''
141+
indent_str = ' ' * level
142+
for key, value in diff.items():
143+
# filter out ["path", "api_type_link", "api_link", "declaration", "status"]
144+
if isinstance(value, dict):
145+
if key in ['api_types', 'apis', 'sub_apis']:
146+
report += generate_text_report(value, level=level)
147+
else:
148+
status_text = f"{value.get('status', '')}: " if 'status' in value else ''
149+
if status_text:
150+
if print_key:
151+
report += f'{indent_str}{status_text}{key}\n'
152+
else:
153+
report += f'{indent_str}{status_text}\n'
154+
if value.get('declaration'):
155+
for d in value.get('declaration'):
156+
report += f'{indent_str}{d}\n'
157+
else:
158+
report += f'{indent_str}{key}\n'
159+
report += generate_text_report(value, level=level + 1)
160+
161+
return report
162+
163+
164+
def generate_markdown_title(commit, run_id):
165+
pst_now = datetime.datetime.utcnow().astimezone(
166+
pytz.timezone('America/Los_Angeles'))
167+
return (
168+
'## Apple API Diff Report\n' + 'Commit: %s\n' % commit +
169+
'Last updated: %s \n' % pst_now.strftime('%a %b %e %H:%M %Z %G') +
170+
'**[View workflow logs & download artifacts](https://github.com/firebase/firebase-cpp-sdk/actions/runs/%s)**\n\n'
171+
% run_id + '-----\n')
172+
173+
174+
def generate_markdown_report(diff, level=0):
175+
report = ''
176+
header_str = '#' * (level + 3)
177+
for key, value in diff.items():
178+
if isinstance(value, dict):
179+
if key in ['api_types', 'apis', 'sub_apis']:
180+
report += generate_markdown_report(value, level=level)
181+
else:
182+
current_status = value.get('status')
183+
if current_status:
184+
if level == 0: # Module level: Always print out module name as title
185+
report += f'{header_str} {key} [{current_status}]\n'
186+
if current_status != STATUS_ERROR: # ADDED,REMOVED,MODIFIED
187+
report += f'<details>\n<summary>\n[{current_status}] {key}\n</summary>\n\n'
188+
declarations = value.get('declaration', [])
189+
sub_report = generate_text_report(value,
190+
level=1,
191+
print_key=False)
192+
detail = process_declarations(current_status,
193+
declarations, sub_report)
194+
report += f'```diff\n{detail}\n```\n\n</details>\n\n'
195+
else: # no diff at current level
196+
report += f'{header_str} {key}\n'
197+
report += generate_markdown_report(value, level=level + 1)
198+
199+
if level == 0: # Module level: Always print out divider in the end
200+
report += '-----\n'
201+
202+
return report
203+
204+
205+
def process_declarations(current_status, declarations, sub_report):
206+
"""Diff syntax highlighting in Github Markdown."""
207+
detail = ''
208+
if current_status == STATUS_MODIFIED:
209+
for line in (declarations + sub_report.split('\n')):
210+
if STATUS_ADD in line:
211+
prefix = '+ '
212+
continue
213+
elif STATUS_REMOVED in line:
214+
prefix = '- '
215+
continue
216+
if line:
217+
detail += f'{prefix}{line}\n'
218+
else:
219+
prefix = '+ ' if current_status == STATUS_ADD else '- '
220+
for line in (declarations + sub_report.split('\n')):
221+
if line:
222+
detail += f'{prefix}{line}\n'
223+
224+
return categorize_declarations(detail)
225+
226+
227+
def categorize_declarations(detail):
228+
"""Categorize API info by Swift and Objective-C."""
229+
lines = detail.split('\n')
230+
231+
swift_lines = [
232+
line.replace('Swift', '') for line in lines if 'Swift' in line
233+
]
234+
objc_lines = [
235+
line.replace('Objective-C', '') for line in lines
236+
if 'Objective-C' in line
237+
]
238+
239+
swift_detail = 'Swift:\n' + '\n'.join(swift_lines) if swift_lines else ''
240+
objc_detail = 'Objective-C:\n' + '\n'.join(
241+
objc_lines) if objc_lines else ''
242+
243+
if not swift_detail and not objc_detail:
244+
return detail
245+
else:
246+
return f'{swift_detail}\n{objc_detail}'.strip()
247+
248+
249+
def parse_cmdline_args():
250+
parser = argparse.ArgumentParser()
251+
parser.add_argument('-p', '--pr_branch')
252+
parser.add_argument('-b', '--base_branch')
253+
parser.add_argument('-c', '--commit')
254+
parser.add_argument('-i', '--run_id')
255+
256+
args = parser.parse_args()
257+
return args
258+
259+
260+
if __name__ == '__main__':
261+
main()

0 commit comments

Comments
 (0)