|
13 | 13 | import json
|
14 | 14 | import os.path
|
15 | 15 | import pkgutil
|
16 |
| -from typing import Any, Dict, Set, Union |
| 16 | +import sys |
| 17 | +from pathlib import Path |
| 18 | +from typing import Any, Dict, List, NamedTuple, Set, Union |
| 19 | + |
| 20 | +_ARGUMENT_SELF = {"kind": "POSITIONAL_OR_KEYWORD", "name": "self"} |
17 | 21 |
|
18 | 22 |
|
19 | 23 | class InterfaceScanner:
|
@@ -91,21 +95,141 @@ def _print(signature: Dict[str, inspect.Signature], variables: Set[str]) -> None
|
91 | 95 | print(json.dumps(result, indent=2, sort_keys=True))
|
92 | 96 |
|
93 | 97 |
|
| 98 | +class _BreakingChanges(NamedTuple): |
| 99 | + deleted_variables: List[str] |
| 100 | + deleted_routines: List[str] |
| 101 | + incompatible_routines: List[str] |
| 102 | + |
| 103 | + def is_empty(self) -> bool: |
| 104 | + return not any([self.deleted_variables, self.deleted_routines, self.incompatible_routines]) |
| 105 | + |
| 106 | + @staticmethod |
| 107 | + def _argument_to_str(argument: Dict[str, Any]) -> str: |
| 108 | + if "default" in argument: |
| 109 | + return f'{argument["name"]}={argument["default"]}' |
| 110 | + return str(argument["name"]) |
| 111 | + |
| 112 | + def print_markdown( |
| 113 | + self, |
| 114 | + original_routines: Dict[str, List[Dict[str, Any]]], |
| 115 | + routines: Dict[str, List[Dict[str, Any]]], |
| 116 | + ) -> None: |
| 117 | + """Print all breaking changes in markdown.""" |
| 118 | + print("\n# Compatibility breaking changes:") |
| 119 | + print("**These changes are considered breaking changes and may break packages consuming") |
| 120 | + print("the PyPI package [aws-sam-translator](https://pypi.org/project/aws-sam-translator/).") |
| 121 | + print("Please consider revisiting these changes to make sure they are intentional:**") |
| 122 | + if self.deleted_variables: |
| 123 | + print("\n## Deleted module level variables") |
| 124 | + for name in self.deleted_variables: |
| 125 | + print(f"- {name}") |
| 126 | + if self.deleted_variables: |
| 127 | + print("\n## Deleted routines") |
| 128 | + for name in self.deleted_routines: |
| 129 | + print(f"- {name}") |
| 130 | + if self.incompatible_routines: |
| 131 | + print("\n## Deleted routines") |
| 132 | + for name in self.incompatible_routines: |
| 133 | + before = f"({', '.join(self._argument_to_str(arg) for arg in original_routines[name])})" |
| 134 | + after = f"({', '.join(self._argument_to_str(arg) for arg in routines[name])})" |
| 135 | + print(f"- {name}: `{before}` -> `{after}`") |
| 136 | + |
| 137 | + |
| 138 | +def _only_new_optional_arguments_or_existing_arguments_optionalized_or_var_arguments( |
| 139 | + original_arguments: List[Dict[str, Any]], arguments: List[Dict[str, Any]] |
| 140 | +) -> bool: |
| 141 | + if len(original_arguments) > len(arguments): |
| 142 | + return False |
| 143 | + for i, original_argument in enumerate(original_arguments): |
| 144 | + if original_argument == arguments[i]: |
| 145 | + continue |
| 146 | + if ( |
| 147 | + original_argument["name"] == arguments[i]["name"] |
| 148 | + and original_argument["kind"] == arguments[i]["kind"] |
| 149 | + and "default" not in original_argument |
| 150 | + and "default" in arguments[i] |
| 151 | + ): |
| 152 | + continue |
| 153 | + return False |
| 154 | + # it is an optional argument when it has a default value: |
| 155 | + return all( |
| 156 | + [ |
| 157 | + "default" in argument or argument["kind"] in ("VAR_KEYWORD", "VAR_POSITIONAL") |
| 158 | + for argument in arguments[len(original_arguments) :] |
| 159 | + ] |
| 160 | + ) |
| 161 | + |
| 162 | + |
| 163 | +def _is_compatible(original_arguments: List[Dict[str, Any]], arguments: List[Dict[str, Any]]) -> bool: |
| 164 | + """ |
| 165 | + If there is an argument change, it is compatible only when |
| 166 | + - new optional arguments are added or existing arguments become optional. |
| 167 | + - var arguments (*args, **kwargs) are added |
| 168 | + - self is removed (method -> staticmethod). |
| 169 | + - combination of above |
| 170 | + """ |
| 171 | + if ( |
| 172 | + original_arguments == arguments |
| 173 | + or _only_new_optional_arguments_or_existing_arguments_optionalized_or_var_arguments( |
| 174 | + original_arguments, arguments |
| 175 | + ) |
| 176 | + ): |
| 177 | + return True |
| 178 | + if original_arguments and original_arguments[0] == _ARGUMENT_SELF: |
| 179 | + original_arguments_without_self = original_arguments[1:] |
| 180 | + if _is_compatible(original_arguments_without_self, arguments): |
| 181 | + return True |
| 182 | + return False |
| 183 | + |
| 184 | + |
| 185 | +def _detect_breaking_changes( |
| 186 | + original_routines: Dict[str, List[Dict[str, Any]]], |
| 187 | + original_variables: Set[str], |
| 188 | + routines: Dict[str, List[Dict[str, Any]]], |
| 189 | + variables: Set[str], |
| 190 | +) -> _BreakingChanges: |
| 191 | + deleted_routines: List[str] = [] |
| 192 | + incompatible_routines: List[str] = [] |
| 193 | + for routine_path, arguments in original_routines.items(): |
| 194 | + if routine_path not in routines: |
| 195 | + deleted_routines.append(routine_path) |
| 196 | + elif not _is_compatible(arguments, routines[routine_path]): |
| 197 | + incompatible_routines.append(routine_path) |
| 198 | + return _BreakingChanges( |
| 199 | + sorted(set(original_variables) - set(variables)), sorted(deleted_routines), sorted(incompatible_routines) |
| 200 | + ) |
| 201 | + |
| 202 | + |
94 | 203 | def main() -> None:
|
95 | 204 | parser = argparse.ArgumentParser(description=__doc__)
|
96 | 205 |
|
97 | 206 | subparsers = parser.add_subparsers(dest="command")
|
98 | 207 | extract = subparsers.add_parser("extract", help="Extract public interfaces")
|
99 | 208 | extract.add_argument("--module", help="The module to extract public interfaces", type=str, default="samtranslator")
|
| 209 | + check = subparsers.add_parser("check", help="Check public interface changes") |
| 210 | + check.add_argument("original_json", help="The original public interface JSON file", type=Path) |
| 211 | + check.add_argument("new_json", help="The new public interface JSON file", type=Path) |
100 | 212 | args = parser.parse_args()
|
101 | 213 |
|
102 | 214 | if args.command == "extract":
|
103 | 215 | scanner = InterfaceScanner()
|
104 | 216 | scanner.scan_interfaces_recursively(args.module)
|
105 | 217 | _print(scanner.signatures, scanner.variables)
|
106 |
| - # TODO: handle compare |
| 218 | + elif args.command == "check": |
| 219 | + original_json = json.loads(args.original_json.read_text()) |
| 220 | + new_json = json.loads(args.new_json.read_text()) |
| 221 | + breaking_changes = _detect_breaking_changes( |
| 222 | + original_json["routines"], original_json["variables"], new_json["routines"], new_json["variables"] |
| 223 | + ) |
| 224 | + if breaking_changes.is_empty(): |
| 225 | + sys.stderr.write("No compatibility breaking changes detected.\n") |
| 226 | + else: |
| 227 | + sys.stderr.write("Compatibility breaking changes detected!!!\n") |
| 228 | + breaking_changes.print_markdown(original_json["routines"], new_json["routines"]) |
| 229 | + sys.exit(1) |
107 | 230 | else:
|
108 | 231 | parser.print_help()
|
| 232 | + sys.exit(1) |
109 | 233 |
|
110 | 234 |
|
111 | 235 | if __name__ == "__main__":
|
|
0 commit comments