api_diff_report.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  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. import json
  15. import argparse
  16. import logging
  17. import os
  18. import api_info
  19. STATUS_ADD = 'ADDED'
  20. STATUS_REMOVED = 'REMOVED'
  21. STATUS_MODIFIED = 'MODIFIED'
  22. STATUS_ERROR = 'BUILD ERROR'
  23. API_DIFF_FILE_NAME = 'api_diff_report.markdown'
  24. def main():
  25. logging.getLogger().setLevel(logging.INFO)
  26. args = parse_cmdline_args()
  27. new_api_file = os.path.join(os.path.expanduser(args.pr_branch),
  28. api_info.API_INFO_FILE_NAME)
  29. old_api_file = os.path.join(os.path.expanduser(args.base_branch),
  30. api_info.API_INFO_FILE_NAME)
  31. if os.path.exists(new_api_file):
  32. with open(new_api_file) as f:
  33. new_api_json = json.load(f)
  34. else:
  35. new_api_json = {}
  36. if os.path.exists(old_api_file):
  37. with open(old_api_file) as f:
  38. old_api_json = json.load(f)
  39. else:
  40. old_api_json = {}
  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. report = generate_markdown_report(diff)
  46. logging.info(f'markdown diff report: \n{report}')
  47. else:
  48. logging.info('No API Diff Detected.')
  49. report = ""
  50. output_dir = os.path.expanduser(args.output_dir)
  51. if not os.path.exists(output_dir):
  52. os.makedirs(output_dir)
  53. api_report_path = os.path.join(output_dir, API_DIFF_FILE_NAME)
  54. logging.info(f'Writing API diff report to {api_report_path}')
  55. with open(api_report_path, 'w') as f:
  56. f.write(report)
  57. def generate_diff_json(new_api, old_api, level='module'):
  58. """diff_json only contains module & api that has a change.
  59. format:
  60. {
  61. $(module_name_1): {
  62. "api_types": {
  63. $(api_type_1): {
  64. "apis": {
  65. $(api_1): {
  66. "declaration": [
  67. $(api_1_declaration)
  68. ],
  69. "sub_apis": {
  70. $(sub_api_1): {
  71. "declaration": [
  72. $(sub_api_1_declaration)
  73. ]
  74. },
  75. },
  76. "status": $(diff_status)
  77. }
  78. }
  79. }
  80. }
  81. }
  82. }
  83. """
  84. NEXT_LEVEL = {'module': 'api_types', 'api_types': 'apis', 'apis': 'sub_apis'}
  85. next_level = NEXT_LEVEL.get(level)
  86. diff = {}
  87. for key in set(new_api.keys()).union(old_api.keys()):
  88. # Added API
  89. if key not in old_api:
  90. diff[key] = new_api[key]
  91. diff[key]['status'] = STATUS_ADD
  92. if diff[key].get('declaration'):
  93. diff[key]['declaration'] = [STATUS_ADD] + diff[key]['declaration']
  94. # Removed API
  95. elif key not in new_api:
  96. diff[key] = old_api[key]
  97. diff[key]['status'] = STATUS_REMOVED
  98. if diff[key].get('declaration'):
  99. diff[key]['declaration'] = [STATUS_REMOVED] + diff[key]['declaration']
  100. # Module Build Error. If a "module" exist but have no
  101. # content (e.g. doc_path), it must have a build error.
  102. elif level == 'module' and (not new_api[key]['path']
  103. or not old_api[key]['path']):
  104. diff[key] = {'status': STATUS_ERROR}
  105. # Check diff in child level and diff in declaration
  106. else:
  107. child_diff = generate_diff_json(new_api[key][next_level],
  108. old_api[key][next_level],
  109. level=next_level) if next_level else {}
  110. declaration_diff = new_api[key].get('declaration') != old_api[key].get(
  111. 'declaration') if level in ['apis', 'sub_apis'] else False
  112. # No diff
  113. if not child_diff and not declaration_diff:
  114. continue
  115. diff[key] = new_api[key]
  116. # Changes at child level
  117. if child_diff:
  118. diff[key][next_level] = child_diff
  119. # Modified API (changes in API declaration)
  120. if declaration_diff:
  121. diff[key]['status'] = STATUS_MODIFIED
  122. diff[key]['declaration'] = [STATUS_ADD] + \
  123. new_api[key]['declaration'] + \
  124. [STATUS_REMOVED] + \
  125. old_api[key]['declaration']
  126. return diff
  127. def generate_text_report(diff, level=0, print_key=True):
  128. report = ''
  129. indent_str = ' ' * level
  130. for key, value in diff.items():
  131. # filter out ["path", "api_type_link", "api_link", "declaration", "status"]
  132. if isinstance(value, dict):
  133. if key in ['api_types', 'apis', 'sub_apis']:
  134. report += generate_text_report(value, level=level)
  135. else:
  136. status_text = f"{value.get('status', '')}:" if 'status' in value else ''
  137. if status_text:
  138. if print_key:
  139. report += f'{indent_str}{status_text} {key}\n'
  140. else:
  141. report += f'{indent_str}{status_text}\n'
  142. if value.get('declaration'):
  143. for d in value.get('declaration'):
  144. report += f'{indent_str}{d}\n'
  145. else:
  146. report += f'{indent_str}{key}\n'
  147. report += generate_text_report(value, level=level + 1)
  148. return report
  149. def generate_markdown_report(diff, level=0):
  150. report = ''
  151. header_str = '#' * (level + 3)
  152. for key, value in diff.items():
  153. if isinstance(value, dict):
  154. if key in ['api_types', 'apis', 'sub_apis']:
  155. report += generate_markdown_report(value, level=level)
  156. else:
  157. current_status = value.get('status')
  158. if current_status:
  159. # Module level: Always print out module name as title
  160. if level == 0:
  161. report += f'{header_str} {key} [{current_status}]\n'
  162. if current_status != STATUS_ERROR: # ADDED,REMOVED,MODIFIED
  163. report += '<details>\n<summary>\n'
  164. report += f'[{current_status}] {key}\n'
  165. report += '</summary>\n\n'
  166. declarations = value.get('declaration', [])
  167. sub_report = generate_text_report(value, level=1, print_key=False)
  168. detail = process_declarations(current_status, declarations,
  169. sub_report)
  170. report += f'```diff\n{detail}\n```\n\n</details>\n\n'
  171. else: # no diff at current level
  172. report += f'{header_str} {key}\n'
  173. report += generate_markdown_report(value, level=level + 1)
  174. # Module level: Always print out divider in the end
  175. if level == 0:
  176. report += '-----\n'
  177. return report
  178. def process_declarations(current_status, declarations, sub_report):
  179. """Diff syntax highlighting in Github Markdown."""
  180. detail = ''
  181. if current_status == STATUS_MODIFIED:
  182. for line in (declarations + sub_report.split('\n')):
  183. if STATUS_ADD in line:
  184. prefix = '+ '
  185. continue
  186. elif STATUS_REMOVED in line:
  187. prefix = '- '
  188. continue
  189. if line:
  190. detail += f'{prefix}{line}\n'
  191. else:
  192. prefix = '+ ' if current_status == STATUS_ADD else '- '
  193. for line in (declarations + sub_report.split('\n')):
  194. if line:
  195. detail += f'{prefix}{line}\n'
  196. return categorize_declarations(detail)
  197. def categorize_declarations(detail):
  198. """Categorize API info by Swift and Objective-C."""
  199. lines = detail.split('\n')
  200. swift_lines = [line.replace('Swift', '') for line in lines if 'Swift' in line]
  201. objc_lines = [
  202. line.replace('Objective-C', '') for line in lines if 'Objective-C' in line
  203. ]
  204. swift_detail = 'Swift:\n' + '\n'.join(swift_lines) if swift_lines else ''
  205. objc_detail = 'Objective-C:\n' + '\n'.join(objc_lines) if objc_lines else ''
  206. if not swift_detail and not objc_detail:
  207. return detail
  208. else:
  209. return f'{swift_detail}\n{objc_detail}'.strip()
  210. def parse_cmdline_args():
  211. parser = argparse.ArgumentParser()
  212. parser.add_argument('-p', '--pr_branch')
  213. parser.add_argument('-b', '--base_branch')
  214. parser.add_argument('-o', '--output_dir', default='output_dir')
  215. args = parser.parse_args()
  216. return args
  217. if __name__ == '__main__':
  218. main()