Skip to content

Commit 756f075

Browse files
Merge branch 'main' into v-gsampath/hdfs
2 parents 06c6cbb + e86ae3a commit 756f075

624 files changed

Lines changed: 89392 additions & 41895 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.vscode/cspell.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2209,6 +2209,11 @@
22092209
"words": ["envml", "paginators"]
22102210
},
22112211
{
2212+
"filename": "sdk/planetarycomputer/azure-planetarycomputer/**",
2213+
"words": ["topo", "haline", "Aproperty", "pygen"]
2214+
},
2215+
{
2216+
"filename": "sdk/agentserver/azure-ai-agentserver-githubcopilot/**",
22122217
"filename": "sdk/agentserver/azure-ai-agentserver-ghcopilot/**",
22132218
"words": ["RAPI", "BYOK", "byok", "NCUS", "ncusacr", "fstring", "ename", "valeriepham", "coreai", "Vnext", "PYTHONIOENCODING"]
22142219
}

eng/common/scripts/Helpers/DevOps-WorkItem-Helpers.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,6 +1205,7 @@ function Get-ReleasePlan-Link($releasePlanWorkItemId)
12051205
$fields += "System.Title"
12061206
$fields += "Custom.ReleasePlanLink"
12071207
$fields += "Custom.ReleasePlanSubmittedby"
1208+
$fields += "Custom.ReleasePlanID"
12081209

12091210
$fieldList = ($fields | ForEach-Object { "[$_]"}) -join ", "
12101211
$query = "SELECT ${fieldList} FROM WorkItems WHERE [System.Id] = $releasePlanWorkItemId"

scripts/breaking_changes_checker/breaking_changes_allowlist.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
("RemovedOrRenamedPositionalParam", "*", "*", "as_dict", "key_transformer"),
3838
("RemovedOrRenamedPositionalParam", "*", "*", "as_dict"),
3939
("RemovedFunctionKwargs", "*", "*", "as_dict"),
40+
("ChangedFunctionReturnType", "*", "*", "as_dict"),
4041
# operation group can't be instantiated independently so don't need check for it
4142
("RemovedOrRenamedPositionalParam", "*", "*", "__init__", "client"),
4243
("RemovedOrRenamedPositionalParam", "*", "*", "__init__", "config"),
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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+
import os
8+
import re
9+
import sys
10+
11+
sys.path.append(os.path.abspath("../../scripts/breaking_changes_checker"))
12+
from _models import CheckerType
13+
import jsondiff
14+
15+
16+
# Pairs of return-type names that are semantically equivalent in the Azure SDK
17+
# and should not be flagged as a breaking change. The concrete paged types
18+
# (`ItemPaged` / `AsyncItemPaged`) implement the corresponding abstract
19+
# iterable protocols (`Iterable` / `AsyncIterable`), so a switch between them
20+
# is a no-op for callers. The `typing` generic aliases (`Dict`, `List`, ...)
21+
# are equivalent to the PEP 585 builtin generics (`dict`, `list`, ...).
22+
_EQUIVALENT_RETURN_TYPES = (
23+
("ItemPaged", "Iterable"),
24+
("AsyncItemPaged", "AsyncIterable"),
25+
("Dict", "dict"),
26+
("List", "list"),
27+
("Tuple", "tuple"),
28+
("Set", "set"),
29+
("FrozenSet", "frozenset"),
30+
("Type", "type"),
31+
)
32+
33+
34+
def _normalize_return_type(value):
35+
"""Normalize known-equivalent return type spellings so the comparison
36+
treats e.g. ``AsyncIterable[X]`` and ``AsyncItemPaged[X]`` as identical.
37+
Module-qualified names (e.g. ``azure.core.paging.ItemPaged``) are also
38+
matched. Returns ``value`` unchanged if it is not a string.
39+
"""
40+
if not isinstance(value, str):
41+
return value
42+
normalized = value
43+
for concrete, abstract in _EQUIVALENT_RETURN_TYPES:
44+
# Collapse both the concrete and abstract spellings (with any
45+
# module-path prefix, e.g. ``azure.core.paging.ItemPaged`` or
46+
# ``typing.Iterable``) down to the bare abstract name. This way
47+
# ``typing.AsyncIterable[X]`` and
48+
# ``azure.core.async_paging.AsyncItemPaged[X]`` both normalize to
49+
# ``AsyncIterable[X]``.
50+
normalized = re.sub(
51+
rf"(?:\w+\.)*(?:{concrete}|{abstract})\b",
52+
abstract,
53+
normalized,
54+
)
55+
return normalized
56+
57+
58+
class ChangedFunctionReturnTypeChecker:
59+
"""Detect a change to a function/method return type annotation.
60+
61+
Resolves https://github.com/Azure/azure-sdk-for-python/issues/46489 - the
62+
breaking-changes detector previously did not capture or compare return type
63+
annotations for normal (non-`@overload`) callables, so a regression like
64+
``LROPoller[Fleet]`` -> ``LROPoller[None]`` was undetected.
65+
66+
The checker is invoked twice per module by the dispatcher in
67+
:class:`BreakingChangesTracker`: once with the class methods of every class
68+
(``class_name`` provided) and once with the module-level functions
69+
(``class_name`` is ``None``).
70+
71+
To stay backwards compatible with reports generated by older versions of
72+
the tool that did not record ``return_type``, the checker silently skips a
73+
function whose stable snapshot does not include the field.
74+
"""
75+
76+
node_type = CheckerType.FUNCTION_OR_METHOD
77+
name = "ChangedFunctionReturnType"
78+
is_breaking = True
79+
message = {
80+
"method": "Method `{}.{}` changed return type from `{}` to `{}`",
81+
"function": "Function `{}` changed return type from `{}` to `{}`",
82+
}
83+
84+
def run_check(self, diff, stable_nodes, current_nodes, **kwargs):
85+
module_name = kwargs.get("module_name")
86+
class_name = kwargs.get("class_name")
87+
bc_list = []
88+
89+
# New module: nothing to compare against.
90+
if module_name not in stable_nodes:
91+
return bc_list
92+
if class_name is not None and class_name not in stable_nodes[module_name].get("class_nodes", {}):
93+
return bc_list
94+
95+
for function_name, function_components in diff.items():
96+
# Skip removed/renamed entries; those are handled by other checkers.
97+
if isinstance(function_name, jsondiff.Symbol):
98+
continue
99+
# `function_components` from the jsondiff view may not contain the
100+
# full record. Resolve current/stable values by lookup so we always
101+
# compare the authoritative snapshots, mirroring the pattern in
102+
# `RemovedMethodOverloadChecker`.
103+
stable_fn = self._lookup(stable_nodes, module_name, class_name, function_name)
104+
current_fn = self._lookup(current_nodes, module_name, class_name, function_name)
105+
if not isinstance(stable_fn, dict) or not isinstance(current_fn, dict):
106+
continue
107+
# Legacy stable reports may not have captured `return_type`.
108+
# Treat missing-in-stable as "unknown" rather than as a breaking
109+
# change to avoid noisy false positives during rollout.
110+
if "return_type" not in stable_fn:
111+
continue
112+
stable_value = stable_fn.get("return_type")
113+
current_value = current_fn.get("return_type")
114+
if stable_value is None or stable_value == current_value:
115+
continue
116+
# Treat known-equivalent paged return types as unchanged
117+
# (e.g. `AsyncIterable[X]` <-> `AsyncItemPaged[X]`).
118+
if _normalize_return_type(stable_value) == _normalize_return_type(current_value):
119+
continue
120+
if class_name:
121+
bc_list.append(
122+
(
123+
self.message["method"], self.name, module_name, class_name, function_name,
124+
stable_value, current_value,
125+
)
126+
)
127+
else:
128+
bc_list.append(
129+
(
130+
self.message["function"], self.name, module_name, function_name,
131+
stable_value, current_value,
132+
)
133+
)
134+
return bc_list
135+
136+
@staticmethod
137+
def _lookup(nodes, module_name, class_name, function_name):
138+
module = nodes.get(module_name, {})
139+
if class_name:
140+
return (
141+
module.get("class_nodes", {})
142+
.get(class_name, {})
143+
.get("methods", {})
144+
.get(function_name)
145+
)
146+
return module.get("function_nodes", {}).get(function_name)

scripts/breaking_changes_checker/detect_breaking_changes.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,41 @@ def get_properties(cls: Type) -> Dict:
239239
return attribute_names
240240

241241

242+
def _is_overload_decorator(dec: ast.expr) -> bool:
243+
"""Return True if the AST decorator node is `@overload` or `@typing.overload`."""
244+
# Bare `@overload`
245+
if isinstance(dec, ast.Name) and dec.id == "overload":
246+
return True
247+
# Qualified `@typing.overload` (or any `pkg.overload`)
248+
if isinstance(dec, ast.Attribute) and dec.attr == "overload":
249+
return True
250+
return False
251+
252+
253+
def _find_function_def_in_body(
254+
body, target_name: str
255+
) -> Optional[Union[ast.FunctionDef, ast.AsyncFunctionDef]]:
256+
"""Return the first non-overload (Async)FunctionDef in ``body`` matching ``target_name``.
257+
258+
Only scans direct children of ``body`` -- does NOT recurse -- so a method
259+
lookup is scoped to the owning class body and a module-level function
260+
lookup is scoped to the module's top level.
261+
"""
262+
for node in body:
263+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
264+
continue
265+
if node.name != target_name:
266+
continue
267+
# Skip @overload stubs - handled separately by `get_overload_data`.
268+
if any(_is_overload_decorator(dec) for dec in node.decorator_list):
269+
continue
270+
return node
271+
return None
272+
273+
242274
def create_function_report(f: Callable, is_async: bool = False) -> Dict:
243275
function = inspect.signature(f)
244-
func_obj = {"parameters": {}, "is_async": is_async}
276+
func_obj = {"parameters": {}, "is_async": is_async, "return_type": None}
245277

246278
for par in function.parameters.values():
247279
default_value = get_parameter_default(par)
@@ -262,6 +294,54 @@ def create_function_report(f: Callable, is_async: bool = False) -> Dict:
262294
param[par.name]["param_type"] = param_type
263295
func_obj["parameters"].update(param)
264296

297+
# Capture the return type annotation by inspecting the source AST. We use
298+
# the AST rather than `inspect.signature(f).return_annotation` because the
299+
# latter resolves to live type objects whose `str()` representation differs
300+
# from the source-level annotation (e.g. fully qualified module paths,
301+
# generic alias quirks). Using AST keeps the captured value consistent with
302+
# how parameter types are recorded for overloads via `get_parameter_type`.
303+
#
304+
# Scope the lookup so we don't pick up an unrelated same-named function or
305+
# a method on a different class in the same module:
306+
# - For methods (qualname like "Cls.method"), search only the owning
307+
# ClassDef's direct body.
308+
# - For module-level functions, search only the module's top-level body.
309+
try:
310+
lookup_target = f
311+
try:
312+
lookup_target = inspect.unwrap(f)
313+
except (AttributeError, ValueError, TypeError):
314+
# Fall back to the original callable when unwrap is not possible.
315+
lookup_target = f
316+
317+
source_path = inspect.getsourcefile(lookup_target)
318+
target_name = getattr(lookup_target, "__name__", None)
319+
qualname = getattr(lookup_target, "__qualname__", target_name) or ""
320+
if source_path and target_name:
321+
module_ast = _get_parsed_module(source_path)
322+
target_node = None
323+
# Heuristic: a qualname of the form "Owner.method" with no
324+
# "<locals>" segment indicates a method bound to a class named
325+
# by the first qualname component.
326+
qualname_parts = qualname.split(".")
327+
if (
328+
len(qualname_parts) >= 2
329+
and qualname_parts[-1] == target_name
330+
and "<locals>" not in qualname_parts
331+
):
332+
owner_class = qualname_parts[-2]
333+
cls_node = _find_class_node(module_ast, owner_class)
334+
if cls_node is not None:
335+
target_node = _find_function_def_in_body(cls_node.body, target_name)
336+
if target_node is None:
337+
# Module-level function (or fallback if class lookup failed).
338+
target_node = _find_function_def_in_body(module_ast.body, target_name)
339+
if target_node is not None and target_node.returns is not None:
340+
func_obj["return_type"] = get_parameter_type(target_node.returns)
341+
except (TypeError, OSError, SyntaxError) as exc:
342+
# Built-ins, C-implemented functions, or unreadable source files.
343+
_LOGGER.debug("Unable to capture return type for %r: %s", f, exc)
344+
265345
return func_obj
266346

267347

@@ -276,6 +356,8 @@ def get_parameter_default_ast(default):
276356

277357

278358
def get_parameter_type(annotation) -> str:
359+
if annotation is None:
360+
return None
279361
if isinstance(annotation, ast.Name):
280362
return annotation.id
281363
if isinstance(annotation, ast.Attribute):
@@ -291,7 +373,26 @@ def get_parameter_type(annotation) -> str:
291373
return f"{get_parameter_type(annotation.value)}[{get_parameter_type(annotation.slice)}]"
292374
if isinstance(annotation, ast.Tuple):
293375
return ", ".join([get_parameter_type(el) for el in annotation.elts])
294-
return annotation
376+
# PEP 604 union syntax (e.g. ``int | None``) parses as ``ast.BinOp`` with
377+
# ``ast.BitOr``. Represent it as a ``Union[...]`` string so the captured
378+
# value remains JSON-serializable.
379+
if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
380+
parts = []
381+
def _flatten(node):
382+
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
383+
_flatten(node.left)
384+
_flatten(node.right)
385+
else:
386+
parts.append(get_parameter_type(node))
387+
_flatten(annotation)
388+
return f"Union[{', '.join(str(p) for p in parts)}]"
389+
# Fall back to a source-level string representation so we never return a
390+
# raw AST node (which would not be JSON-serializable when the report is
391+
# written via ``json.dump``).
392+
try:
393+
return ast.unparse(annotation)
394+
except Exception: # pylint: disable=broad-except
395+
return str(annotation)
295396

296397

297398
def create_parameters(args: ast.arg) -> Dict:
@@ -391,11 +492,14 @@ def get_overload_data(node: ast.ClassDef, cls_methods: Dict) -> None:
391492
is_async = True
392493
# method_overloads.update({func.name: {"parameters": {}, "is_async": False, "return_type": None}})
393494
for decorator in func.decorator_list:
394-
if hasattr(decorator, "id") and decorator.id == "overload":
495+
if _is_overload_decorator(decorator):
496+
overload_return_type = None
497+
if func.returns is not None:
498+
overload_return_type = get_parameter_type(func.returns)
395499
overload_report = {
396500
"parameters": create_parameters(func.args),
397501
"is_async": is_async,
398-
"return_type": None,
502+
"return_type": overload_return_type,
399503
}
400504
cls_methods[func.name]["overloads"].append(overload_report)
401505

scripts/breaking_changes_checker/supported_checkers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77

88
from checkers.removed_method_overloads_checker import RemovedMethodOverloadChecker
99
from checkers.added_method_overloads_checker import AddedMethodOverloadChecker
10+
from checkers.changed_function_return_type_checker import ChangedFunctionReturnTypeChecker
1011
from checkers.unflattened_models_checker import UnflattenedModelsChecker
1112

1213
CHECKERS = [
1314
RemovedMethodOverloadChecker(),
1415
AddedMethodOverloadChecker(),
16+
ChangedFunctionReturnTypeChecker(),
1517
]
1618

1719
POST_PROCESSING_CHECKERS = [

scripts/breaking_changes_checker/tests/examples/return_type_capture/__init__.py

Whitespace-only changes.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# --------------------------------------------------------------------------------------------
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License. See License.txt in the project root for license information.
4+
# --------------------------------------------------------------------------------------------
5+
"""Fixture exercising return-type capture in `create_function_report`.
6+
7+
Includes two classes that share a method name (``list``) and a module-level
8+
function that also shares the same name. This pins down the regression where
9+
the previous AST walk would match the first same-named function in the file,
10+
regardless of class scope.
11+
12+
It also covers ``@typing.overload``-decorated stubs to ensure the AST walk
13+
skips them and reads the implementation's return annotation.
14+
"""
15+
import typing
16+
17+
18+
class FooOperations:
19+
def list(self) -> str: # noqa: A003
20+
return ""
21+
22+
@typing.overload
23+
def fetch(self, value: int) -> int: ...
24+
@typing.overload
25+
def fetch(self, value: str) -> str: ...
26+
def fetch(self, value) -> "typing.Union[int, str]":
27+
return value
28+
29+
30+
class BarOperations:
31+
def list(self) -> bytes: # noqa: A003
32+
return b""
33+
34+
35+
def list() -> int: # noqa: A001
36+
return 0

0 commit comments

Comments
 (0)