Skip to content

Commit 8efafcb

Browse files
authored
API Diff Report tools (fix python format) (#11235)
1 parent e7c5c97 commit 8efafcb

File tree

11 files changed

+915
-0
lines changed

11 files changed

+915
-0
lines changed
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
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(f'markdown diff report: \n{generate_markdown_report(diff)}')
46+
else:
47+
logging.info('No API Diff Detected.')
48+
49+
50+
def generate_diff_json(new_api, old_api, level='module'):
51+
"""diff_json only contains module & api that has a change.
52+
53+
format:
54+
{
55+
$(module_name_1): {
56+
"api_types": {
57+
$(api_type_1): {
58+
"apis": {
59+
$(api_1): {
60+
"declaration": [
61+
$(api_1_declaration)
62+
],
63+
"sub_apis": {
64+
$(sub_api_1): {
65+
"declaration": [
66+
$(sub_api_1_declaration)
67+
]
68+
},
69+
},
70+
"status": $(diff_status)
71+
}
72+
}
73+
}
74+
}
75+
}
76+
}
77+
"""
78+
NEXT_LEVEL = {'module': 'api_types', 'api_types': 'apis', 'apis': 'sub_apis'}
79+
next_level = NEXT_LEVEL.get(level)
80+
81+
diff = {}
82+
for key in set(new_api.keys()).union(old_api.keys()):
83+
# Added API
84+
if key not in old_api:
85+
diff[key] = new_api[key]
86+
diff[key]['status'] = STATUS_ADD
87+
if diff[key].get('declaration'):
88+
diff[key]['declaration'] = [STATUS_ADD] + diff[key]['declaration']
89+
# Removed API
90+
elif key not in new_api:
91+
diff[key] = old_api[key]
92+
diff[key]['status'] = STATUS_REMOVED
93+
if diff[key].get('declaration'):
94+
diff[key]['declaration'] = [STATUS_REMOVED] + diff[key]['declaration']
95+
# Module Build Error. If a "module" exist but have no
96+
# content (e.g. doc_path), it must have a build error.
97+
elif level == 'module' and (not new_api[key]['path']
98+
or not old_api[key]['path']):
99+
diff[key] = {'status': STATUS_ERROR}
100+
# Check diff in child level and diff in declaration
101+
else:
102+
child_diff = generate_diff_json(new_api[key][next_level],
103+
old_api[key][next_level],
104+
level=next_level) if next_level else {}
105+
declaration_diff = new_api[key].get('declaration') != old_api[key].get(
106+
'declaration') if level in ['apis', 'sub_apis'] else False
107+
108+
# No diff
109+
if not child_diff and not declaration_diff:
110+
continue
111+
112+
diff[key] = new_api[key]
113+
# Changes at child level
114+
if child_diff:
115+
diff[key][next_level] = child_diff
116+
117+
# Modified API (changes in API declaration)
118+
if declaration_diff:
119+
diff[key]['status'] = STATUS_MODIFIED
120+
diff[key]['declaration'] = [STATUS_ADD] + \
121+
new_api[key]['declaration'] + \
122+
[STATUS_REMOVED] + \
123+
old_api[key]['declaration']
124+
125+
return diff
126+
127+
128+
def generate_text_report(diff, level=0, print_key=True):
129+
report = ''
130+
indent_str = ' ' * level
131+
for key, value in diff.items():
132+
# filter out ["path", "api_type_link", "api_link", "declaration", "status"]
133+
if isinstance(value, dict):
134+
if key in ['api_types', 'apis', 'sub_apis']:
135+
report += generate_text_report(value, level=level)
136+
else:
137+
status_text = f"{value.get('status', '')}:" if 'status' in value else ''
138+
if status_text:
139+
if print_key:
140+
report += f'{indent_str}{status_text} {key}\n'
141+
else:
142+
report += f'{indent_str}{status_text}\n'
143+
if value.get('declaration'):
144+
for d in value.get('declaration'):
145+
report += f'{indent_str}{d}\n'
146+
else:
147+
report += f'{indent_str}{key}\n'
148+
report += generate_text_report(value, level=level + 1)
149+
150+
return report
151+
152+
153+
def generate_markdown_title(commit, run_id):
154+
pst_now = datetime.datetime.utcnow().astimezone(
155+
pytz.timezone('America/Los_Angeles'))
156+
return (
157+
'## Apple API Diff Report\n' + 'Commit: %s\n' % commit
158+
+ 'Last updated: %s \n' % pst_now.strftime('%a %b %e %H:%M %Z %G')
159+
+ '**[View workflow logs & download artifacts]'
160+
+ '(https://github.com/firebase/firebase-ios-sdk/actions/runs/%s)**\n\n'
161+
% run_id + '-----\n')
162+
163+
164+
def generate_markdown_report(diff, level=0):
165+
report = ''
166+
header_str = '#' * (level + 3)
167+
for key, value in diff.items():
168+
if isinstance(value, dict):
169+
if key in ['api_types', 'apis', 'sub_apis']:
170+
report += generate_markdown_report(value, level=level)
171+
else:
172+
current_status = value.get('status')
173+
if current_status:
174+
# Module level: Always print out module name as title
175+
if level == 0:
176+
report += f'{header_str} {key} [{current_status}]\n'
177+
if current_status != STATUS_ERROR: # ADDED,REMOVED,MODIFIED
178+
report += '<details>\n<summary>\n'
179+
report += f'[{current_status}] {key}\n'
180+
report += '</summary>\n\n'
181+
declarations = value.get('declaration', [])
182+
sub_report = generate_text_report(value, level=1, print_key=False)
183+
detail = process_declarations(current_status, declarations,
184+
sub_report)
185+
report += f'```diff\n{detail}\n```\n\n</details>\n\n'
186+
else: # no diff at current level
187+
report += f'{header_str} {key}\n'
188+
report += generate_markdown_report(value, level=level + 1)
189+
# Module level: Always print out divider in the end
190+
if level == 0:
191+
report += '-----\n'
192+
193+
return report
194+
195+
196+
def process_declarations(current_status, declarations, sub_report):
197+
"""Diff syntax highlighting in Github Markdown."""
198+
detail = ''
199+
if current_status == STATUS_MODIFIED:
200+
for line in (declarations + sub_report.split('\n')):
201+
if STATUS_ADD in line:
202+
prefix = '+ '
203+
continue
204+
elif STATUS_REMOVED in line:
205+
prefix = '- '
206+
continue
207+
if line:
208+
detail += f'{prefix}{line}\n'
209+
else:
210+
prefix = '+ ' if current_status == STATUS_ADD else '- '
211+
for line in (declarations + sub_report.split('\n')):
212+
if line:
213+
detail += f'{prefix}{line}\n'
214+
215+
return categorize_declarations(detail)
216+
217+
218+
def categorize_declarations(detail):
219+
"""Categorize API info by Swift and Objective-C."""
220+
lines = detail.split('\n')
221+
222+
swift_lines = [line.replace('Swift', '') for line in lines if 'Swift' in line]
223+
objc_lines = [
224+
line.replace('Objective-C', '') for line in lines if 'Objective-C' in line
225+
]
226+
227+
swift_detail = 'Swift:\n' + '\n'.join(swift_lines) if swift_lines else ''
228+
objc_detail = 'Objective-C:\n' + '\n'.join(objc_lines) if objc_lines else ''
229+
230+
if not swift_detail and not objc_detail:
231+
return detail
232+
else:
233+
return f'{swift_detail}\n{objc_detail}'.strip()
234+
235+
236+
def parse_cmdline_args():
237+
parser = argparse.ArgumentParser()
238+
parser.add_argument('-p', '--pr_branch')
239+
parser.add_argument('-b', '--base_branch')
240+
parser.add_argument('-c', '--commit')
241+
parser.add_argument('-i', '--run_id')
242+
243+
args = parser.parse_args()
244+
return args
245+
246+
247+
if __name__ == '__main__':
248+
main()

0 commit comments

Comments
 (0)