Skip to content

Commit 362d68d

Browse files
authored
Deduplicate changelog entries from generated types module (#48109)
* Deduplicate changelog entries from generated types module The TypeSpec generated 'types' module contains TypedDict input aliases that shadow the real models in the sibling 'models' module. Newly added models were therefore reported twice in the changelog (e.g. 'Added model Foo'). Add a ShadowTypesModuleChecker post-processing step that drops changelog/breaking-change entries originating from a shadow 'types' module, and add tests. * Guard shadow types cleanup by sibling models class existence Address review feedback: only drop a types-module entry when the sibling models module actually contains a class of the same name, so a types-only change (no models counterpart) is preserved in the breaking-change/changelog output. Add regression tests for the class-existence guard. * Remove unused sys.path.append from shadow types checker * Match shadow types entries against actual models change tuple Only drop a types-module entry when the sibling models module reports the same change type and arguments (with member names normalized snake_case to bridge the types wire-name vs models attribute-name difference). This avoids silently dropping an unmatched types-only member change while still deduping the real property/model duplicates. Add member-level regression tests. * Normalize only member-name position in shadow types match Compare class names and semantic values exactly and normalize only the member-name position to snake_case, avoiding false collisions on class names, type strings, or defaults. Add a regression test asserting exact class-name matching. * Index shadow types match keys for O(n) lookup Pre-index sibling models match keys once so each types entry is checked with a constant-time set lookup instead of rescanning the whole change list (avoids an O(n^2) regression on large diffs). Handle list-valued change args when building keys. Add a scalability test. Also aligns the PR description with the exact class-name matching behavior. * update test * update readme
1 parent c3af616 commit 362d68d

7 files changed

Lines changed: 374 additions & 2 deletions

File tree

scripts/breaking_changes_checker/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,17 @@ C:\azure-sdk-for-python\sdk\storage\azure-storage-blob> azpysdk breaking . --cha
200200

201201
> **Note:** The apistub-based report is *functionally equivalent* to the import-based report (same namespaces, classes, enums, methods/overloads, properties and module functions) but not byte-identical — enum values reflect member names and the inherited `str`/`Mapping` boilerplate is omitted. Since both sides of a comparison use the same converter, those differences cancel out in the diff.
202202
203+
## Run tests
204+
205+
The default test command runs the full suite, including slow integration tests that generate full code reports for large packages:
206+
207+
```bash
208+
python -m pytest tests/
209+
```
210+
211+
Skip those slow tests for a faster local signal:
212+
213+
```bash
214+
python -m pytest tests/ --fast
215+
```
216+
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env python
2+
3+
# --------------------------------------------------------------------------------------------
4+
# Copyright (c) Microsoft Corporation. All rights reserved.
5+
# Licensed under the MIT License. See License.txt in the project root for license information.
6+
# --------------------------------------------------------------------------------------------
7+
8+
# --------------------------------------------------------------------------------------------
9+
# This checker cleans up duplicate reporting caused by the generated `types` module.
10+
# TypeSpec generated libraries emit a `types` module containing `TypedDict` input aliases
11+
# that shadow the real models defined in the sibling `models` module. Because the same class
12+
# name exists in both modules, changes such as adding a new model are reported twice, e.g.
13+
# "Added model `Foo`" appears once for `...models` and once for `...types`.
14+
#
15+
# A `types` entry is only dropped when the sibling `models` module reports the *same* change
16+
# (same change type and arguments, with the module substituted). This ensures we never silently
17+
# drop a change that only exists on the `types` side (which would otherwise disappear from the
18+
# breaking-change / changelog output). Class names and semantic values (type strings, defaults)
19+
# are compared exactly; only the member-name position is normalized to snake_case, because the
20+
# `types` TypedDicts expose wire names (e.g. `serviceTreeId`) while the `models` classes expose
21+
# the Python attribute names (e.g. `service_tree_id`).
22+
# --------------------------------------------------------------------------------------------
23+
24+
import re
25+
26+
27+
def _to_snake_case(name):
28+
if not isinstance(name, str):
29+
return name
30+
name = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
31+
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
32+
return name.lower()
33+
34+
35+
def _hashable(value):
36+
# Change tuples may carry list arguments (e.g. parameter-ordering changes); convert them to
37+
# tuples so the resulting match key can live in a set.
38+
if isinstance(value, list):
39+
return tuple(_hashable(item) for item in value)
40+
return value
41+
42+
43+
class ShadowTypesModuleChecker:
44+
def run_check(self, breaking_changes: list, features_added: list, *, diff: dict, stable_nodes: dict, current_nodes: dict, **kwargs) -> tuple[list, list]:
45+
def _sibling_models_module(module_name):
46+
if not isinstance(module_name, str):
47+
return None
48+
if module_name != "types" and not module_name.endswith(".types"):
49+
return None
50+
return module_name[: -len("types")] + "models"
51+
52+
def _match_key(change, module):
53+
# Key is (change_type, module, class_name, [member_name], *rest). The class name
54+
# (args[0]) and any trailing semantic values are compared exactly; only the member
55+
# name (args[1]) is normalized so that `types` wire names match `models` attribute
56+
# names.
57+
args = change[3:]
58+
normalized_args = tuple(
59+
_hashable(_to_snake_case(arg) if index == 1 else arg) for index, arg in enumerate(args)
60+
)
61+
return (change[1], module) + normalized_args
62+
63+
def _filter(changes_list):
64+
# Pre-index the match key of every change once so each `types` entry can be checked
65+
# with a single O(1) set lookup instead of rescanning the whole list (avoids O(n^2)).
66+
candidate_keys = {_match_key(change, change[2]) for change in changes_list if len(change) > 3}
67+
result = []
68+
for change in changes_list:
69+
sibling_models = _sibling_models_module(change[2])
70+
# Drop a `types` entry only when the sibling `models` module reports the same
71+
# change. A module-level change (len <= 3) has no class to match, so keep it.
72+
if (
73+
sibling_models is not None
74+
and len(change) > 3
75+
and _match_key(change, sibling_models) in candidate_keys
76+
):
77+
continue
78+
result.append(change)
79+
return result
80+
81+
return _filter(breaking_changes), _filter(features_added)

scripts/breaking_changes_checker/supported_checkers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from checkers.removed_method_overloads_checker import RemovedMethodOverloadChecker
99
from checkers.added_method_overloads_checker import AddedMethodOverloadChecker
1010
from checkers.changed_function_return_type_checker import ChangedFunctionReturnTypeChecker
11+
from checkers.shadow_types_module_checker import ShadowTypesModuleChecker
1112

1213
CHECKERS = [
1314
RemovedMethodOverloadChecker(),
@@ -16,5 +17,5 @@
1617
]
1718

1819
POST_PROCESSING_CHECKERS = [
19-
# Add any post-processing checkers here
20+
ShadowTypesModuleChecker(),
2021
]
Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,19 @@
1+
import pytest
2+
3+
4+
def pytest_addoption(parser):
5+
parser.addoption("--fast", action="store_true", default=False, help="skip slow integration tests")
6+
7+
18
def pytest_configure(config):
2-
config.addinivalue_line("markers", "slow: tests that may take up to 10 minutes")
9+
config.addinivalue_line("markers", "slow: tests that may take several minutes")
10+
11+
12+
def pytest_collection_modifyitems(config, items):
13+
if not config.getoption("--fast"):
14+
return
15+
16+
skip_slow = pytest.mark.skip(reason="skipped by --fast")
17+
for item in items:
18+
if "slow" in item.keywords:
19+
item.add_marker(skip_slow)

scripts/breaking_changes_checker/tests/test_changelog.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
sys.path.insert(0, path)
1919

2020
from breaking_changes_checker.checkers.added_method_overloads_checker import AddedMethodOverloadChecker
21+
from breaking_changes_checker.checkers.shadow_types_module_checker import ShadowTypesModuleChecker
2122
from breaking_changes_checker.changelog_tracker import ChangelogTracker, BreakingChangesTracker
2223
from breaking_changes_checker.detect_breaking_changes import main
2324
from breaking_changes_checker.detect_breaking_changes import test_compare_reports as compare_reports
@@ -151,6 +152,223 @@ def test_async_cleanup_check():
151152
assert msg == ChangelogTracker.ADDED_CLASS_PROPERTY_MSG
152153

153154

155+
def test_shadow_types_module_cleanup():
156+
# A TypeSpec generated `types` module holds TypedDict input aliases that shadow the
157+
# real models in the sibling `models` module. A newly added model therefore shows up in
158+
# both modules and would be reported twice (e.g. "Added model `TrustedHostSubscription`").
159+
# The ShadowTypesModuleChecker post-processing step should drop the `types` duplicate.
160+
stable = {
161+
"azure.mgmt.computelimit.models": {
162+
"class_nodes": {
163+
"ExistingModel": {"type": None, "methods": {}, "properties": {}},
164+
}
165+
},
166+
"azure.mgmt.computelimit.types": {
167+
"class_nodes": {
168+
"ExistingModel": {"type": None, "methods": {}, "properties": {}},
169+
}
170+
},
171+
}
172+
173+
current = {
174+
"azure.mgmt.computelimit.models": {
175+
"class_nodes": {
176+
"ExistingModel": {"type": None, "methods": {}, "properties": {}},
177+
"TrustedHostSubscription": {"type": None, "methods": {}, "properties": {}},
178+
}
179+
},
180+
"azure.mgmt.computelimit.types": {
181+
"class_nodes": {
182+
"ExistingModel": {"type": None, "methods": {}, "properties": {}},
183+
"TrustedHostSubscription": {"type": None, "methods": {}, "properties": {}},
184+
}
185+
},
186+
}
187+
188+
bc = ChangelogTracker(
189+
stable, current, "azure-mgmt-computelimit", post_processing_checkers=[ShadowTypesModuleChecker()]
190+
)
191+
bc.run_checks()
192+
193+
added_models = [fa for fa in bc.features_added if fa[0] == ChangelogTracker.ADDED_CLASS_MSG]
194+
# Should only have 1 "Added model" reported instead of 2 (the `types` duplicate is removed).
195+
assert len(added_models) == 1
196+
_, _, module_name, class_name = added_models[0]
197+
assert module_name == "azure.mgmt.computelimit.models"
198+
assert class_name == "TrustedHostSubscription"
199+
# No change originating from the shadow `types` module should remain.
200+
assert not any(fa[2] == "azure.mgmt.computelimit.types" for fa in bc.features_added)
201+
202+
203+
def test_shadow_types_module_kept_without_models_sibling():
204+
# If there is no sibling `models` module, a `types` module is a real public module and
205+
# its changes should NOT be dropped.
206+
stable = {
207+
"azure.mgmt.computelimit.types": {
208+
"class_nodes": {
209+
"ExistingModel": {"type": None, "methods": {}, "properties": {}},
210+
}
211+
},
212+
}
213+
214+
current = {
215+
"azure.mgmt.computelimit.types": {
216+
"class_nodes": {
217+
"ExistingModel": {"type": None, "methods": {}, "properties": {}},
218+
"TrustedHostSubscription": {"type": None, "methods": {}, "properties": {}},
219+
}
220+
},
221+
}
222+
223+
bc = ChangelogTracker(
224+
stable, current, "azure-mgmt-computelimit", post_processing_checkers=[ShadowTypesModuleChecker()]
225+
)
226+
bc.run_checks()
227+
228+
added_models = [fa for fa in bc.features_added if fa[0] == ChangelogTracker.ADDED_CLASS_MSG]
229+
assert len(added_models) == 1
230+
_, _, module_name, class_name = added_models[0]
231+
assert module_name == "azure.mgmt.computelimit.types"
232+
assert class_name == "TrustedHostSubscription"
233+
234+
235+
def test_shadow_types_module_kept_when_class_missing_in_models():
236+
# If the sibling `models` module exists but does NOT contain the class, the `types`
237+
# class has no `models` counterpart and its change must be preserved.
238+
stable = {
239+
"azure.mgmt.computelimit.models": {
240+
"class_nodes": {
241+
"ExistingModel": {"type": None, "methods": {}, "properties": {}},
242+
}
243+
},
244+
"azure.mgmt.computelimit.types": {
245+
"class_nodes": {
246+
"ExistingModel": {"type": None, "methods": {}, "properties": {}},
247+
}
248+
},
249+
}
250+
251+
current = {
252+
"azure.mgmt.computelimit.models": {
253+
"class_nodes": {
254+
"ExistingModel": {"type": None, "methods": {}, "properties": {}},
255+
}
256+
},
257+
"azure.mgmt.computelimit.types": {
258+
"class_nodes": {
259+
"ExistingModel": {"type": None, "methods": {}, "properties": {}},
260+
# Only present in the `types` module, no counterpart in `models`.
261+
"OrphanInput": {"type": None, "methods": {}, "properties": {}},
262+
}
263+
},
264+
}
265+
266+
bc = ChangelogTracker(
267+
stable, current, "azure-mgmt-computelimit", post_processing_checkers=[ShadowTypesModuleChecker()]
268+
)
269+
bc.run_checks()
270+
271+
added_models = [fa for fa in bc.features_added if fa[0] == ChangelogTracker.ADDED_CLASS_MSG]
272+
assert len(added_models) == 1
273+
_, _, module_name, class_name = added_models[0]
274+
assert module_name == "azure.mgmt.computelimit.types"
275+
assert class_name == "OrphanInput"
276+
277+
278+
def test_shadow_types_module_breaking_change_preserved_for_unmatched_member():
279+
# A `types` change is dropped only when the sibling `models` module reports the *same*
280+
# change. Here `OrphanInput` is removed only from `types` (no matching `models` entry), so it
281+
# must be preserved, while the `RealModel` addition is mirrored in `models` and thus deduped.
282+
checker = ShadowTypesModuleChecker()
283+
breaking_changes = [
284+
# `OrphanInput` only ever existed in `types`, so its removal is a real breaking change.
285+
("Deleted model `{}`", "RemovedOrRenamedClass", "azure.mgmt.computelimit.types", "OrphanInput"),
286+
]
287+
features_added = [
288+
# `RealModel` is added in both modules, so the `types` copy is a shadow duplicate.
289+
("Added model `{}`", "AddedClass", "azure.mgmt.computelimit.models", "RealModel"),
290+
("Added model `{}`", "AddedClass", "azure.mgmt.computelimit.types", "RealModel"),
291+
]
292+
293+
bc_out, fa_out = checker.run_check(
294+
breaking_changes, features_added, diff={}, stable_nodes={}, current_nodes={}
295+
)
296+
297+
assert bc_out == breaking_changes # unmatched `types` breaking change preserved
298+
assert len(fa_out) == 1 # shadow duplicate removed, `models` entry kept
299+
assert fa_out[0][2] == "azure.mgmt.computelimit.models"
300+
301+
302+
def test_shadow_types_module_member_change_preserved_when_not_mirrored():
303+
# An unmatched *member* change on a class that exists in both modules must be preserved:
304+
# `RealModel` loses `orphanProp` only on the `types` side, `models` reports nothing.
305+
checker = ShadowTypesModuleChecker()
306+
breaking_changes = [
307+
(
308+
"Model `{}` removed property `{}`",
309+
"RemovedOrRenamedInstanceAttribute",
310+
"azure.mgmt.computelimit.types",
311+
"RealModel",
312+
"orphanProp",
313+
),
314+
]
315+
316+
bc_out, fa_out = checker.run_check(
317+
breaking_changes, [], diff={}, stable_nodes={}, current_nodes={}
318+
)
319+
320+
assert bc_out == breaking_changes # no matching `models` change -> preserved
321+
assert fa_out == []
322+
323+
324+
def test_shadow_types_module_member_change_deduped_across_naming():
325+
# A member change reported in both modules is deduped even though `types` uses the wire name
326+
# (`serviceTreeId`) and `models` uses the Python attribute name (`service_tree_id`).
327+
checker = ShadowTypesModuleChecker()
328+
breaking_changes = [
329+
(
330+
"Model `{}` removed property `{}`",
331+
"RemovedOrRenamedInstanceAttribute",
332+
"azure.mgmt.computelimit.models",
333+
"FeatureEnableRequest",
334+
"service_tree_id",
335+
),
336+
(
337+
"Model `{}` removed property `{}`",
338+
"RemovedOrRenamedInstanceAttribute",
339+
"azure.mgmt.computelimit.types",
340+
"FeatureEnableRequest",
341+
"serviceTreeId",
342+
),
343+
]
344+
345+
bc_out, _ = checker.run_check(
346+
breaking_changes, [], diff={}, stable_nodes={}, current_nodes={}
347+
)
348+
349+
assert len(bc_out) == 1 # `types` duplicate removed despite the camelCase/snake_case names
350+
assert bc_out[0][2] == "azure.mgmt.computelimit.models"
351+
352+
353+
def test_shadow_types_module_class_name_matched_exactly():
354+
# Class names are compared exactly (not snake_case normalized), so a `types` class whose
355+
# name only collides with a differently-named `models` class after normalization is kept.
356+
checker = ShadowTypesModuleChecker()
357+
features_added = [
358+
# Different class names that would collide if class names were snake_cased
359+
# (`FooBar` -> `foo_bar`, `Foo_Bar` -> `foo_bar`).
360+
("Added model `{}`", "AddedClass", "azure.mgmt.computelimit.models", "Foo_Bar"),
361+
("Added model `{}`", "AddedClass", "azure.mgmt.computelimit.types", "FooBar"),
362+
]
363+
364+
_, fa_out = checker.run_check(
365+
[], features_added, diff={}, stable_nodes={}, current_nodes={}
366+
)
367+
368+
# No exact class-name match in `models`, so the `types` entry is preserved.
369+
assert len(fa_out) == 2
370+
371+
154372
def test_new_class_property_added_init():
155373
# Testing if a property is added both in the init and at the class level that we only get 1 report for it
156374
current = {

0 commit comments

Comments
 (0)