Skip to content

Commit afc68ac

Browse files
authored
ci: Add public interface command check (#2872)
1 parent 25378e2 commit afc68ac

File tree

2 files changed

+301
-2
lines changed

2 files changed

+301
-2
lines changed

bin/public_interface.py

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
import json
1414
import os.path
1515
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"}
1721

1822

1923
class InterfaceScanner:
@@ -91,21 +95,141 @@ def _print(signature: Dict[str, inspect.Signature], variables: Set[str]) -> None
9195
print(json.dumps(result, indent=2, sort_keys=True))
9296

9397

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+
94203
def main() -> None:
95204
parser = argparse.ArgumentParser(description=__doc__)
96205

97206
subparsers = parser.add_subparsers(dest="command")
98207
extract = subparsers.add_parser("extract", help="Extract public interfaces")
99208
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)
100212
args = parser.parse_args()
101213

102214
if args.command == "extract":
103215
scanner = InterfaceScanner()
104216
scanner.scan_interfaces_recursively(args.module)
105217
_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)
107230
else:
108231
parser.print_help()
232+
sys.exit(1)
109233

110234

111235
if __name__ == "__main__":

tests/bin/test_public_interface.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
from unittest import TestCase
2+
3+
from bin.public_interface import _BreakingChanges, _detect_breaking_changes
4+
5+
6+
class TestDetectBreakingChanges(TestCase):
7+
def test_missing_variables(self):
8+
self.assertEqual(
9+
_detect_breaking_changes(
10+
{},
11+
["samtranslator.model.CONST_X", "samtranslator.model.CONST_Y"],
12+
{},
13+
["samtranslator.model.CONST_X", "samtranslator.model.CONST_Z"],
14+
),
15+
_BreakingChanges(
16+
deleted_variables=["samtranslator.model.CONST_Y"], deleted_routines=[], incompatible_routines=[]
17+
),
18+
)
19+
20+
def test_missing_routines(self):
21+
self.assertEqual(
22+
_detect_breaking_changes(
23+
{"samtranslator.model.do_something": []},
24+
[],
25+
{"samtranslator.model.do_something_2": []},
26+
[],
27+
),
28+
_BreakingChanges(
29+
deleted_variables=[], deleted_routines=["samtranslator.model.do_something"], incompatible_routines=[]
30+
),
31+
)
32+
33+
def test_routines_still_compatible_when_adding_optional_arguments(self):
34+
self.assertEqual(
35+
_detect_breaking_changes(
36+
{"samtranslator.model.do_something": []},
37+
[],
38+
{
39+
"samtranslator.model.do_something": [
40+
{"name": "new_arg", "kind": "POSITIONAL_OR_KEYWORD", "default": False},
41+
{"name": "new_arg_2", "kind": "POSITIONAL_OR_KEYWORD", "default": None},
42+
]
43+
},
44+
[],
45+
),
46+
_BreakingChanges(deleted_variables=[], deleted_routines=[], incompatible_routines=[]),
47+
)
48+
49+
def test_routines_still_compatible_when_optionalize_existing_arguments(self):
50+
self.assertEqual(
51+
_detect_breaking_changes(
52+
{
53+
"samtranslator.model.do_something": [
54+
{
55+
"name": "arg",
56+
"kind": "POSITIONAL_OR_KEYWORD",
57+
},
58+
{
59+
"name": "arg_2",
60+
"kind": "POSITIONAL_OR_KEYWORD",
61+
},
62+
]
63+
},
64+
[],
65+
{
66+
"samtranslator.model.do_something": [
67+
{"name": "arg", "kind": "POSITIONAL_OR_KEYWORD", "default": False},
68+
{"name": "arg_2", "kind": "POSITIONAL_OR_KEYWORD", "default": None},
69+
]
70+
},
71+
[],
72+
),
73+
_BreakingChanges(deleted_variables=[], deleted_routines=[], incompatible_routines=[]),
74+
)
75+
76+
def test_routines_still_compatible_when_adding_var_arguments(self):
77+
self.assertEqual(
78+
_detect_breaking_changes(
79+
{"samtranslator.model.do_something": []},
80+
[],
81+
{
82+
"samtranslator.model.do_something": [
83+
{"name": "args", "kind": "VAR_POSITIONAL"},
84+
{"name": "kwargs", "kind": "VAR_KEYWORD"},
85+
]
86+
},
87+
[],
88+
),
89+
_BreakingChanges(deleted_variables=[], deleted_routines=[], incompatible_routines=[]),
90+
)
91+
92+
def test_routines_still_compatible_when_converting_from_method_to_staticmethod(self):
93+
self.assertEqual(
94+
_detect_breaking_changes(
95+
{
96+
"samtranslator.model.do_something": [
97+
{"kind": "POSITIONAL_OR_KEYWORD", "name": "self"},
98+
{"name": "some_arg", "kind": "POSITIONAL_OR_KEYWORD"},
99+
]
100+
},
101+
[],
102+
{"samtranslator.model.do_something": [{"name": "some_arg", "kind": "POSITIONAL_OR_KEYWORD"}]},
103+
[],
104+
),
105+
_BreakingChanges(deleted_variables=[], deleted_routines=[], incompatible_routines=[]),
106+
)
107+
108+
def test_routines_still_compatible_when_converting_from_method_to_staticmethod_and_adding_optional_arguments(self):
109+
self.assertEqual(
110+
_detect_breaking_changes(
111+
{
112+
"samtranslator.model.do_something": [
113+
{"kind": "POSITIONAL_OR_KEYWORD", "name": "self"},
114+
{"name": "some_arg", "kind": "POSITIONAL_OR_KEYWORD"},
115+
]
116+
},
117+
[],
118+
{
119+
"samtranslator.model.do_something": [
120+
{"name": "some_arg", "kind": "POSITIONAL_OR_KEYWORD"},
121+
{"name": "new_arg", "kind": "POSITIONAL_OR_KEYWORD", "default": False},
122+
{"name": "new_arg_2", "kind": "POSITIONAL_OR_KEYWORD", "default": None},
123+
]
124+
},
125+
[],
126+
),
127+
_BreakingChanges(deleted_variables=[], deleted_routines=[], incompatible_routines=[]),
128+
)
129+
130+
def test_routines_incompatible_when_changing_default_value(self):
131+
self.assertEqual(
132+
_detect_breaking_changes(
133+
{
134+
"samtranslator.model.do_something": [
135+
{"kind": "POSITIONAL_OR_KEYWORD", "name": "self"},
136+
{"name": "some_arg", "kind": "POSITIONAL_OR_KEYWORD", "default": 0},
137+
]
138+
},
139+
[],
140+
{
141+
"samtranslator.model.do_something": [
142+
{"kind": "POSITIONAL_OR_KEYWORD", "name": "self"},
143+
{"name": "some_arg", "kind": "POSITIONAL_OR_KEYWORD", "default": 1},
144+
]
145+
},
146+
[],
147+
),
148+
_BreakingChanges(
149+
deleted_variables=[], deleted_routines=[], incompatible_routines=["samtranslator.model.do_something"]
150+
),
151+
)
152+
153+
def test_routines_incompatible_when_add_new_arguments(self):
154+
self.assertEqual(
155+
_detect_breaking_changes(
156+
{
157+
"samtranslator.model.do_something": [
158+
{"kind": "POSITIONAL_OR_KEYWORD", "name": "self"},
159+
{"name": "some_arg", "kind": "POSITIONAL_OR_KEYWORD"},
160+
]
161+
},
162+
[],
163+
{
164+
"samtranslator.model.do_something": [
165+
{"kind": "POSITIONAL_OR_KEYWORD", "name": "self"},
166+
{"name": "some_arg", "kind": "POSITIONAL_OR_KEYWORD"},
167+
{"name": "new_arg", "kind": "POSITIONAL_OR_KEYWORD"},
168+
]
169+
},
170+
[],
171+
),
172+
_BreakingChanges(
173+
deleted_variables=[], deleted_routines=[], incompatible_routines=["samtranslator.model.do_something"]
174+
),
175+
)

0 commit comments

Comments
 (0)