api_diff_report.py 8.1 KB

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