From e5011bbda0968329d9207f861ebce5bfc5991946 Mon Sep 17 00:00:00 2001 From: Yang An Date: Thu, 30 Apr 2026 14:51:17 +1000 Subject: [PATCH 1/3] Fix batch module Python 3.14 typing compatibility In Python 3.14, several breaking changes affect how type annotations are represented: - The private _name attribute was removed from Union/Optional types - str(Optional[X]) now produces X | None (pipe syntax) instead of yping.Optional[X] - get_type_hints() resolves ForwardRef strings to actual classes, so str() on resolved types produces fully-qualified names like zure.batch.models._models.Foo instead of ForwardRef('_models.Foo') Fix by replacing all version-specific introspection with stable public APIs: 1. get_optional_state(): replace _name == 'Optional' check with ype(None) in get_args(type_hint), which works identically in all Python versions. 2. get_track1_attribute_map(): replace raw cls.__annotations__ iteration (which contains unresolved ForwardRef strings) with get_type_hints() to get resolved types, then use get_args() to detect optional/union patterns instead of regex on string representations. 3. convert_to_track1_type(): add handlers for: - Top-level pipe union syntax: A | None -> A - Inner pipe union syntax: List[str | SomeType] -> List[SomeType] - string representations (in addition to ) - Fully-qualified zure.batch.models. and _enums. prefixes produced when get_type_hints() resolves ForwardRefs to real classes Verified: batch.json doc output is bit-for-bit identical between Python 3.13 and Python 3.14 after this fix. --- .../command_modules/batch/_command_type.py | 81 ++++++++++++++----- 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/batch/_command_type.py b/src/azure-cli/azure/cli/command_modules/batch/_command_type.py index 4004e1e5a70..b6380278b2b 100644 --- a/src/azure-cli/azure/cli/command_modules/batch/_command_type.py +++ b/src/azure-cli/azure/cli/command_modules/batch/_command_type.py @@ -689,11 +689,26 @@ def get_track1_validations(self, cls): return filtered_members def convert_to_track1_type(self, original_type): + # Handle Python 3.14 pipe union syntax at the top level: "A | B | None" + # Only applies when not inside brackets (e.g. not List[A | B]) + if original_type is not None and " | " in original_type and "[" not in original_type: + parts = [p.strip() for p in original_type.split(' | ')] + non_none_parts = [p for p in parts if p != 'None'] + if non_none_parts: + original_type = non_none_parts[0] + # Handle Python 3.14 pipe union syntax inside brackets: "List[str | SomeType]" + # Replace inner "str | X" with just "X" + if original_type is not None and " | " in original_type: + original_type = re.sub(r'str \| (\w)', r'\1', original_type) if original_type is not None and "ForwardRef" in original_type: pattern = r"ForwardRef\('_models\.(.*?)'\)" original_type = re.sub(pattern, r'\1', original_type) if original_type is not None and "_models." in original_type: original_type = original_type.replace("_models.", "") + if original_type is not None and "_enums." in original_type: + original_type = original_type.replace("_enums.", "") + if original_type is not None and "azure.batch.models." in original_type: + original_type = original_type.replace("azure.batch.models.", "") if original_type is not None and "typing.List" in original_type: original_type = original_type.replace("typing.List", "List") if original_type is not None and "typing.Dict" in original_type: @@ -710,8 +725,8 @@ def convert_to_track1_type(self, original_type): pattern = r"typing\.Union\[str, (.+?)\]" original_type = re.sub(pattern, r"\1", original_type) - if original_type is not None and "" + if original_type is not None and ("" match = re.search(pattern, original_type) if match: original_type = match.group(1) @@ -738,25 +753,52 @@ def get_track1_rest_names(self, cls): def get_track1_attribute_map(self, cls): # pylint: disable=protected-access member_types = {} - pattern1 = r"^typing\.Union\[str, (.+), NoneType\]$" - pattern2 = r"^typing\.Union\[(.+), NoneType\]$" - pattern3 = r"^typing\.Optional\[(.+)\]$" rest_names = self.get_track1_rest_names(cls) - for name, typ in cls.__annotations__.items(): - if hasattr(typ, '_name') and typ._name is not None and typ._name == 'Optional': - track1_type = self.convert_to_track1_type(str(get_args(typ)[0])) + + # Use get_type_hints to resolve ForwardRef strings and get resolved type information + globalns = {} + globalns.update(vars(importlib.import_module(cls.__module__))) + # azure batch models uses an alias _models which throws off the get_type_hints eval, need this to correct + globalns['_models'] = importlib.import_module('azure.batch.models') + hints = get_type_hints(cls, globalns=globalns) + + for name, type_hint in hints.items(): + args = get_args(type_hint) + + # Check if this is an optional type (Union with None) + is_optional = type(None) in args + + if is_optional: + # Extract non-None types from the union + non_none_args = tuple(arg for arg in args if arg is not type(None)) + if non_none_args: + # Use the first non-None type (or first non-str type if multiple) + if len(non_none_args) == 1: + track1_type = self.convert_to_track1_type(str(non_none_args[0])) + else: + # Multiple non-None types: prefer first non-str type + track1_type = None + for arg in non_none_args: + if arg != str: + track1_type = self.convert_to_track1_type(str(arg)) + break + if track1_type is None: + # All were str or similar, use first + track1_type = self.convert_to_track1_type(str(non_none_args[0])) + else: + # No non-None args (shouldn't happen), use original + track1_type = self.convert_to_track1_type(str(type_hint)) else: - track1_type = str(typ) - - if re.match(pattern1, track1_type): - track1_type = self.convert_to_track1_type(str(get_args(typ)[1])) - elif re.match(pattern2, track1_type): - track1_type = self.convert_to_track1_type(str(get_args(typ)[0])) - elif re.match(pattern3, track1_type): - track1_type = self.convert_to_track1_type(str(get_args(typ)[0])) + # Not optional. If it's a Union (e.g. Union[str, SomeEnum]), extract the non-str type. + if args and str in args: + non_str_args = [a for a in args if a != str] + if non_str_args: + track1_type = self.convert_to_track1_type(str(non_str_args[0])) + else: + track1_type = self.convert_to_track1_type(str(type_hint)) else: - track1_type = self.convert_to_track1_type(track1_type) + track1_type = self.convert_to_track1_type(str(type_hint)) if rest_names[name] is None: print("none") @@ -776,8 +818,9 @@ def get_optional_state(self, cls): members = get_type_hints(cls, globalns=globalns) filtered_members = {} for name, type_hint in members.items(): - is_optional = (type_hint._name == 'Optional' or type_hint._name is None - if hasattr(type_hint, '_name') else False) + # Use get_args() to detect optional types (stable across Python 3.13 and 3.14) + args = get_args(type_hint) + is_optional = type(None) in args filtered_members[name] = {'required': not is_optional} return filtered_members From 14dc288af340bffbecc8ba047391d381efc3168e Mon Sep 17 00:00:00 2001 From: Yang An Date: Thu, 30 Apr 2026 15:44:55 +1000 Subject: [PATCH 2/3] Address PR review feedback for batch typing fix - prefer non-str member for top-level pipe unions - make inner pipe cleanup regex robust to whitespace - add _enums alias mapping for get_type_hints - remove debug print from attribute map - keep _models alias mapped to azure.batch.models exports --- .../cli/command_modules/batch/_command_type.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/batch/_command_type.py b/src/azure-cli/azure/cli/command_modules/batch/_command_type.py index b6380278b2b..d89b6ab6e44 100644 --- a/src/azure-cli/azure/cli/command_modules/batch/_command_type.py +++ b/src/azure-cli/azure/cli/command_modules/batch/_command_type.py @@ -695,11 +695,14 @@ def convert_to_track1_type(self, original_type): parts = [p.strip() for p in original_type.split(' | ')] non_none_parts = [p for p in parts if p != 'None'] if non_none_parts: - original_type = non_none_parts[0] + if len(non_none_parts) > 1: + original_type = next((p for p in non_none_parts if p != 'str'), non_none_parts[0]) + else: + original_type = non_none_parts[0] # Handle Python 3.14 pipe union syntax inside brackets: "List[str | SomeType]" # Replace inner "str | X" with just "X" if original_type is not None and " | " in original_type: - original_type = re.sub(r'str \| (\w)', r'\1', original_type) + original_type = re.sub(r'\bstr\b\s*\|\s*', '', original_type) if original_type is not None and "ForwardRef" in original_type: pattern = r"ForwardRef\('_models\.(.*?)'\)" original_type = re.sub(pattern, r'\1', original_type) @@ -759,8 +762,10 @@ def get_track1_attribute_map(self, cls): # Use get_type_hints to resolve ForwardRef strings and get resolved type information globalns = {} globalns.update(vars(importlib.import_module(cls.__module__))) - # azure batch models uses an alias _models which throws off the get_type_hints eval, need this to correct + # Azure Batch model annotations use aliases like `_models.Foo` and `_enums.Bar`. + # `_models` aliases resolve via azure.batch.models exports; `_enums` points to the generated enums module. globalns['_models'] = importlib.import_module('azure.batch.models') + globalns['_enums'] = importlib.import_module('azure.batch.models._enums') hints = get_type_hints(cls, globalns=globalns) for name, type_hint in hints.items(): @@ -800,8 +805,6 @@ def get_track1_attribute_map(self, cls): else: track1_type = self.convert_to_track1_type(str(type_hint)) - if rest_names[name] is None: - print("none") member_types[name] = {'key': rest_names[name], 'type': track1_type} return member_types @@ -812,8 +815,10 @@ def get_optional_state(self, cls): globalns = {} # Add the global namespace of the module where the class is defined globalns.update(vars(importlib.import_module(cls.__module__))) - # azure batch models uses an alias _models which throws off the get_type_hints eval, need this to correct + # Azure Batch model annotations use aliases like `_models.Foo` and `_enums.Bar`. + # `_models` aliases resolve via azure.batch.models exports; `_enums` points to the generated enums module. globalns['_models'] = importlib.import_module('azure.batch.models') + globalns['_enums'] = importlib.import_module('azure.batch.models._enums') members = get_type_hints(cls, globalns=globalns) filtered_members = {} From b9d3ee538c8a87326f6e2cde1f3bad9ace8d80d5 Mon Sep 17 00:00:00 2001 From: Yang An Date: Thu, 30 Apr 2026 17:11:44 +1000 Subject: [PATCH 3/3] Refactor batch track1 type hint resolution --- .../command_modules/batch/_command_type.py | 56 +++++++------------ 1 file changed, 20 insertions(+), 36 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/batch/_command_type.py b/src/azure-cli/azure/cli/command_modules/batch/_command_type.py index d89b6ab6e44..f50d6e803be 100644 --- a/src/azure-cli/azure/cli/command_modules/batch/_command_type.py +++ b/src/azure-cli/azure/cli/command_modules/batch/_command_type.py @@ -753,6 +753,25 @@ def get_track1_rest_names(self, cls): rest_names[name] = rest_name return rest_names + def _resolve_track1_type_hint(self, type_hint): + """Resolve type hints to the legacy track1 type string format.""" + args = get_args(type_hint) + + # Optional[T] / Union[..., None] -> select the best non-None candidate. + if type(None) in args: + non_none_args = [arg for arg in args if arg is not type(None)] + preferred_args = [arg for arg in non_none_args if arg != str] or non_none_args + selected = preferred_args[0] if preferred_args else type_hint + return self.convert_to_track1_type(str(selected)) + + # Union[str, X] -> prefer X for command argument flattening. + if args and str in args: + non_str_args = [arg for arg in args if arg != str] + if non_str_args: + return self.convert_to_track1_type(str(non_str_args[0])) + + return self.convert_to_track1_type(str(type_hint)) + def get_track1_attribute_map(self, cls): # pylint: disable=protected-access member_types = {} @@ -769,42 +788,7 @@ def get_track1_attribute_map(self, cls): hints = get_type_hints(cls, globalns=globalns) for name, type_hint in hints.items(): - args = get_args(type_hint) - - # Check if this is an optional type (Union with None) - is_optional = type(None) in args - - if is_optional: - # Extract non-None types from the union - non_none_args = tuple(arg for arg in args if arg is not type(None)) - if non_none_args: - # Use the first non-None type (or first non-str type if multiple) - if len(non_none_args) == 1: - track1_type = self.convert_to_track1_type(str(non_none_args[0])) - else: - # Multiple non-None types: prefer first non-str type - track1_type = None - for arg in non_none_args: - if arg != str: - track1_type = self.convert_to_track1_type(str(arg)) - break - if track1_type is None: - # All were str or similar, use first - track1_type = self.convert_to_track1_type(str(non_none_args[0])) - else: - # No non-None args (shouldn't happen), use original - track1_type = self.convert_to_track1_type(str(type_hint)) - else: - # Not optional. If it's a Union (e.g. Union[str, SomeEnum]), extract the non-str type. - if args and str in args: - non_str_args = [a for a in args if a != str] - if non_str_args: - track1_type = self.convert_to_track1_type(str(non_str_args[0])) - else: - track1_type = self.convert_to_track1_type(str(type_hint)) - else: - track1_type = self.convert_to_track1_type(str(type_hint)) - + track1_type = self._resolve_track1_type_hint(type_hint) member_types[name] = {'key': rest_names[name], 'type': track1_type} return member_types