api_diff_report.py 8.2 KB

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