Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package azure-cli-core for openSUSE:Factory checked in at 2026-03-04 21:07:14 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/azure-cli-core (Old) and /work/SRC/openSUSE:Factory/.azure-cli-core.new.561 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "azure-cli-core" Wed Mar 4 21:07:14 2026 rev:93 rq:1336190 version:2.84.0 Changes: -------- --- /work/SRC/openSUSE:Factory/azure-cli-core/azure-cli-core.changes 2026-02-17 18:29:21.426931021 +0100 +++ /work/SRC/openSUSE:Factory/.azure-cli-core.new.561/azure-cli-core.changes 2026-03-04 21:07:59.955341477 +0100 @@ -1,0 +2,8 @@ +Tue Mar 3 07:12:42 UTC 2026 - John Paul Adrian Glaubitz <[email protected]> + +- New upstream release + + Version 2.84.0 + + For detailed information about changes see the + HISTORY.rst file provided with this package + +------------------------------------------------------------------- Old: ---- azure_cli_core-2.83.0.tar.gz New: ---- azure_cli_core-2.84.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ azure-cli-core.spec ++++++ --- /var/tmp/diff_new_pack.t7tyHJ/_old 2026-03-04 21:08:00.779375629 +0100 +++ /var/tmp/diff_new_pack.t7tyHJ/_new 2026-03-04 21:08:00.783375795 +0100 @@ -24,7 +24,7 @@ %global _sitelibdir %{%{pythons}_sitelib} Name: azure-cli-core -Version: 2.83.0 +Version: 2.84.0 Release: 0 Summary: Microsoft Azure CLI Core Module License: MIT ++++++ azure_cli_core-2.83.0.tar.gz -> azure_cli_core-2.84.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/azure_cli_core-2.83.0/HISTORY.rst new/azure_cli_core-2.84.0/HISTORY.rst --- old/azure_cli_core-2.83.0/HISTORY.rst 2026-01-27 08:23:53.000000000 +0100 +++ new/azure_cli_core-2.84.0/HISTORY.rst 2026-02-25 03:35:23.000000000 +0100 @@ -3,6 +3,10 @@ Release History =============== +2.84.0 +++++++ +* Minor fixes + 2.83.0 ++++++ * Resolve CVE-2025-69277 (#32610) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/azure_cli_core-2.83.0/PKG-INFO new/azure_cli_core-2.84.0/PKG-INFO --- old/azure_cli_core-2.83.0/PKG-INFO 2026-01-27 08:24:42.918434900 +0100 +++ new/azure_cli_core-2.84.0/PKG-INFO 2026-02-25 03:36:22.626516000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: azure-cli-core -Version: 2.83.0 +Version: 2.84.0 Summary: Microsoft Azure Command-Line Tools Core Module Home-page: https://github.com/Azure/azure-cli Author: Microsoft Corporation diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/azure_cli_core-2.83.0/azure/cli/core/__init__.py new/azure_cli_core-2.84.0/azure/cli/core/__init__.py --- old/azure_cli_core-2.83.0/azure/cli/core/__init__.py 2026-01-27 08:23:53.000000000 +0100 +++ new/azure_cli_core-2.84.0/azure/cli/core/__init__.py 2026-02-25 03:35:23.000000000 +0100 @@ -4,11 +4,13 @@ # -------------------------------------------------------------------------------------------- # pylint: disable=line-too-long -__version__ = "2.83.0" +__version__ = "2.84.0" import os import sys import timeit +import concurrent.futures +from concurrent.futures import ThreadPoolExecutor from knack.cli import CLI from knack.commands import CLICommandsLoader @@ -34,6 +36,10 @@ ALWAYS_LOADED_MODULES = [] # Extensions that will always be loaded if installed. They don't expose commands but hook into CLI core. ALWAYS_LOADED_EXTENSIONS = ['azext_ai_examples', 'azext_next'] +# Timeout (in seconds) for loading a single module. Acts as a safety valve to prevent indefinite hangs +MODULE_LOAD_TIMEOUT_SECONDS = 60 +# Maximum number of worker threads for parallel module loading. +MAX_WORKER_THREAD_COUNT = 4 def _configure_knack(): @@ -197,6 +203,17 @@ format_styled_text.theme = theme +class ModuleLoadResult: # pylint: disable=too-few-public-methods + def __init__(self, module_name, command_table, group_table, elapsed_time, error=None, traceback_str=None, command_loader=None): + self.module_name = module_name + self.command_table = command_table + self.group_table = group_table + self.elapsed_time = elapsed_time + self.error = error + self.traceback_str = traceback_str + self.command_loader = command_loader + + class MainCommandsLoader(CLICommandsLoader): # Format string for pretty-print the command module table @@ -241,11 +258,11 @@ import pkgutil import traceback from azure.cli.core.commands import ( - _load_module_command_loader, _load_extension_command_loader, BLOCKED_MODS, ExtensionCommandSource) + _load_extension_command_loader, ExtensionCommandSource) from azure.cli.core.extension import ( get_extensions, get_extension_path, get_extension_modname) from azure.cli.core.breaking_change import ( - import_core_breaking_changes, import_module_breaking_changes, import_extension_breaking_changes) + import_core_breaking_changes, import_extension_breaking_changes) def _update_command_table_from_modules(args, command_modules=None): """Loads command tables from modules and merge into the main command table. @@ -273,41 +290,17 @@ except ImportError as e: logger.warning(e) - count = 0 - cumulative_elapsed_time = 0 - cumulative_group_count = 0 - cumulative_command_count = 0 - logger.debug("Loading command modules:") - logger.debug(self.header_mod) + start_time = timeit.default_timer() + logger.debug("Loading command modules...") + results = self._load_modules(args, command_modules) - for mod in [m for m in command_modules if m not in BLOCKED_MODS]: - try: - start_time = timeit.default_timer() - module_command_table, module_group_table = _load_module_command_loader(self, args, mod) - import_module_breaking_changes(mod) - for cmd in module_command_table.values(): - cmd.command_source = mod - self.command_table.update(module_command_table) - self.command_group_table.update(module_group_table) + count, cumulative_group_count, cumulative_command_count = \ + self._process_results_with_timing(results) - elapsed_time = timeit.default_timer() - start_time - logger.debug(self.item_format_string, mod, elapsed_time, - len(module_group_table), len(module_command_table)) - count += 1 - cumulative_elapsed_time += elapsed_time - cumulative_group_count += len(module_group_table) - cumulative_command_count += len(module_command_table) - except Exception as ex: # pylint: disable=broad-except - # Changing this error message requires updating CI script that checks for failed - # module loading. - from azure.cli.core import telemetry - logger.error("Error loading command module '%s': %s", mod, ex) - telemetry.set_exception(exception=ex, fault_type='module-load-error-' + mod, - summary='Error loading module: {}'.format(mod)) - logger.debug(traceback.format_exc()) + total_elapsed_time = timeit.default_timer() - start_time # Summary line logger.debug(self.item_format_string, - "Total ({})".format(count), cumulative_elapsed_time, + "Total ({})".format(count), total_elapsed_time, cumulative_group_count, cumulative_command_count) def _update_command_table_from_extensions(ext_suppressions, extension_modname=None): @@ -345,70 +338,80 @@ return filtered_extensions extensions = get_extensions() - if extensions: - if extension_modname is not None: - extension_modname.extend(ALWAYS_LOADED_EXTENSIONS) - extensions = _filter_modname(extensions) - allowed_extensions = _handle_extension_suppressions(extensions) - module_commands = set(self.command_table.keys()) - - count = 0 - cumulative_elapsed_time = 0 - cumulative_group_count = 0 - cumulative_command_count = 0 - logger.debug("Loading extensions:") - logger.debug(self.header_ext) + if not extensions: + return - for ext in allowed_extensions: - try: - # Import in the `for` loop because `allowed_extensions` can be []. In such case we - # don't need to import `check_version_compatibility` at all. - from azure.cli.core.extension.operations import check_version_compatibility - check_version_compatibility(ext.get_metadata()) - except CLIError as ex: - # issue warning and skip loading extensions that aren't compatible with the CLI core - logger.warning(ex) - continue - ext_name = ext.name - ext_dir = ext.path or get_extension_path(ext_name) - sys.path.append(ext_dir) - try: - ext_mod = get_extension_modname(ext_name, ext_dir=ext_dir) - # Add to the map. This needs to happen before we load commands as registering a command - # from an extension requires this map to be up-to-date. - # self._mod_to_ext_map[ext_mod] = ext_name - start_time = timeit.default_timer() - extension_command_table, extension_group_table = \ - _load_extension_command_loader(self, args, ext_mod) - import_extension_breaking_changes(ext_mod) - - for cmd_name, cmd in extension_command_table.items(): - cmd.command_source = ExtensionCommandSource( - extension_name=ext_name, - overrides_command=cmd_name in module_commands, - preview=ext.preview, - experimental=ext.experimental) - - self.command_table.update(extension_command_table) - self.command_group_table.update(extension_group_table) - - elapsed_time = timeit.default_timer() - start_time - logger.debug(self.item_ext_format_string, ext_name, elapsed_time, - len(extension_group_table), len(extension_command_table), - ext_dir) - count += 1 - cumulative_elapsed_time += elapsed_time - cumulative_group_count += len(extension_group_table) - cumulative_command_count += len(extension_command_table) - except Exception as ex: # pylint: disable=broad-except - self.cli_ctx.raise_event(EVENT_FAILED_EXTENSION_LOAD, extension_name=ext_name) - logger.warning("Unable to load extension '%s: %s'. Use --debug for more information.", - ext_name, ex) - logger.debug(traceback.format_exc()) - # Summary line - logger.debug(self.item_ext_format_string, - "Total ({})".format(count), cumulative_elapsed_time, - cumulative_group_count, cumulative_command_count, "") + if extension_modname is not None: + extension_modname.extend(ALWAYS_LOADED_EXTENSIONS) + extensions = _filter_modname(extensions) + allowed_extensions = _handle_extension_suppressions(extensions) + module_commands = set(self.command_table.keys()) + + count = 0 + cumulative_elapsed_time = 0 + cumulative_group_count = 0 + cumulative_command_count = 0 + logger.debug("Loading extensions:") + logger.debug(self.header_ext) + + for ext in allowed_extensions: + try: + # Import in the `for` loop because `allowed_extensions` can be []. In such case we + # don't need to import `check_version_compatibility` at all. + from azure.cli.core.extension.operations import check_version_compatibility + check_version_compatibility(ext.get_metadata()) + except CLIError as ex: + # issue warning and skip loading extensions that aren't compatible with the CLI core + logger.warning(ex) + continue + ext_name = ext.name + ext_dir = ext.path or get_extension_path(ext_name) + sys.path.append(ext_dir) + try: + ext_mod = get_extension_modname(ext_name, ext_dir=ext_dir) + # Add to the map. This needs to happen before we load commands as registering a command + # from an extension requires this map to be up-to-date. + # self._mod_to_ext_map[ext_mod] = ext_name + start_time = timeit.default_timer() + extension_command_table, extension_group_table, extension_command_loader = \ + _load_extension_command_loader(self, args, ext_mod) + import_extension_breaking_changes(ext_mod) + + for cmd_name, cmd in extension_command_table.items(): + cmd.command_source = ExtensionCommandSource( + extension_name=ext_name, + overrides_command=cmd_name in module_commands, + preview=ext.preview, + experimental=ext.experimental) + + # Populate cmd_to_loader_map for extension commands + if extension_command_loader: + self.loaders.append(extension_command_loader) + for cmd_name in extension_command_table: + if cmd_name not in self.cmd_to_loader_map: + self.cmd_to_loader_map[cmd_name] = [] + self.cmd_to_loader_map[cmd_name].append(extension_command_loader) + + self.command_table.update(extension_command_table) + self.command_group_table.update(extension_group_table) + + elapsed_time = timeit.default_timer() - start_time + logger.debug(self.item_ext_format_string, ext_name, elapsed_time, + len(extension_group_table), len(extension_command_table), + ext_dir) + count += 1 + cumulative_elapsed_time += elapsed_time + cumulative_group_count += len(extension_group_table) + cumulative_command_count += len(extension_command_table) + except Exception as ex: # pylint: disable=broad-except + self.cli_ctx.raise_event(EVENT_FAILED_EXTENSION_LOAD, extension_name=ext_name) + logger.warning("Unable to load extension '%s: %s'. Use --debug for more information.", + ext_name, ex) + logger.debug(traceback.format_exc()) + # Summary line + logger.debug(self.item_ext_format_string, + "Total ({})".format(count), cumulative_elapsed_time, + cumulative_group_count, cumulative_command_count, "") def _wrap_suppress_extension_func(func, ext): """ Wrapper method to handle centralization of log messages for extension filters """ @@ -449,6 +452,7 @@ command_index = None # Set fallback=False to turn off command index in case of regression use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) + if use_command_index: command_index = CommandIndex(self.cli_ctx) index_result = command_index.get(args) @@ -522,9 +526,39 @@ if use_command_index: command_index.update(self.command_table) + self._cache_help_index(command_index) return self.command_table + def _display_cached_help(self, help_data, command_path='root'): + """Display help from cached help index without loading modules.""" + # Delegate to the help system for consistent formatting + self.cli_ctx.invocation.help.show_cached_help(help_data, command_path) + + def _cache_help_index(self, command_index): + """Cache help summary for top-level (root) help only.""" + try: + from azure.cli.core.parser import AzCliCommandParser + from azure.cli.core._help import CliGroupHelpFile, extract_help_index_data + + parser = AzCliCommandParser(self.cli_ctx) + parser.load_command_table(self) + + subparser = parser.subparsers.get(tuple()) + if subparser: + help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser) + help_file.load(subparser) + + groups, commands = extract_help_index_data(help_file) + + if groups or commands: + help_index_data = {'groups': groups, 'commands': commands} + command_index.set_help_index(help_index_data) + logger.debug("Cached top-level help with %d groups and %d commands", len(groups), len(commands)) + + except Exception as ex: # pylint: disable=broad-except + logger.debug("Failed to cache help data: %s", ex) + @staticmethod def _sort_command_loaders(command_loaders): module_command_loaders = [] @@ -587,12 +621,115 @@ self.extra_argument_registry.update(loader.extra_argument_registry) loader._update_command_definitions() # pylint: disable=protected-access + def _load_modules(self, args, command_modules): + """Load command modules using ThreadPoolExecutor with timeout protection.""" + from azure.cli.core.commands import BLOCKED_MODS + + results = [] + with ThreadPoolExecutor(max_workers=MAX_WORKER_THREAD_COUNT) as executor: + future_to_module = {executor.submit(self._load_single_module, mod, args): mod + for mod in command_modules if mod not in BLOCKED_MODS} + + try: + for future in concurrent.futures.as_completed(future_to_module, timeout=MODULE_LOAD_TIMEOUT_SECONDS): + try: + result = future.result() + results.append(result) + except (ImportError, AttributeError, TypeError, ValueError) as ex: + mod = future_to_module[future] + logger.warning("Module '%s' load failed: %s", mod, ex) + results.append(ModuleLoadResult(mod, {}, {}, 0, ex)) + except Exception as ex: # pylint: disable=broad-exception-caught + mod = future_to_module[future] + logger.warning("Module '%s' load failed with unexpected exception: %s", mod, ex) + results.append(ModuleLoadResult(mod, {}, {}, 0, ex)) + except concurrent.futures.TimeoutError: + for future, mod in future_to_module.items(): + if future.done(): + try: + result = future.result() + results.append(result) + except Exception as ex: # pylint: disable=broad-exception-caught + logger.warning("Module '%s' load failed: %s", mod, ex) + results.append(ModuleLoadResult(mod, {}, {}, 0, ex)) + else: + logger.warning("Module '%s' load timeout after %s seconds", mod, MODULE_LOAD_TIMEOUT_SECONDS) + results.append(ModuleLoadResult(mod, {}, {}, 0, + Exception(f"Module '{mod}' load timeout"))) + + return results + + def _load_single_module(self, mod, args): + from azure.cli.core.breaking_change import import_module_breaking_changes + from azure.cli.core.commands import _load_module_command_loader + import traceback + try: + start_time = timeit.default_timer() + module_command_table, module_group_table, command_loader = _load_module_command_loader(self, args, mod) + import_module_breaking_changes(mod) + elapsed_time = timeit.default_timer() - start_time + return ModuleLoadResult(mod, module_command_table, module_group_table, elapsed_time, command_loader=command_loader) + except Exception as ex: # pylint: disable=broad-except + tb_str = traceback.format_exc() + return ModuleLoadResult(mod, {}, {}, 0, ex, tb_str) + + def _handle_module_load_error(self, result): + """Handle errors that occurred during module loading.""" + from azure.cli.core import telemetry + + logger.error("Error loading command module '%s': %s", result.module_name, result.error) + telemetry.set_exception(exception=result.error, + fault_type='module-load-error-' + result.module_name, + summary='Error loading module: {}'.format(result.module_name)) + if result.traceback_str: + logger.debug(result.traceback_str) + + def _process_successful_load(self, result): + """Process successfully loaded module results.""" + if result.command_loader: + self.loaders.append(result.command_loader) + + for cmd in result.command_table: + if cmd not in self.cmd_to_loader_map: + self.cmd_to_loader_map[cmd] = [] + self.cmd_to_loader_map[cmd].append(result.command_loader) + + for cmd in result.command_table.values(): + cmd.command_source = result.module_name + + self.command_table.update(result.command_table) + self.command_group_table.update(result.group_table) + + logger.debug(self.item_format_string, result.module_name, result.elapsed_time, + len(result.group_table), len(result.command_table)) + + def _process_results_with_timing(self, results): + """Process pre-loaded module results with timing and progress reporting.""" + logger.debug("Loaded command modules in parallel:") + logger.debug(self.header_mod) + + count = 0 + cumulative_group_count = 0 + cumulative_command_count = 0 + + for result in results: + if result.error: + self._handle_module_load_error(result) + else: + self._process_successful_load(result) + count += 1 + cumulative_group_count += len(result.group_table) + cumulative_command_count += len(result.command_table) + + return count, cumulative_group_count, cumulative_command_count + class CommandIndex: _COMMAND_INDEX = 'commandIndex' _COMMAND_INDEX_VERSION = 'version' _COMMAND_INDEX_CLOUD_PROFILE = 'cloudProfile' + _HELP_INDEX = 'helpIndex' def __init__(self, cli_ctx=None): """Class to manage command index. @@ -606,6 +743,16 @@ self.cloud_profile = cli_ctx.cloud.profile self.cli_ctx = cli_ctx + def _is_index_valid(self): + """Check if the command index version and cloud profile are valid. + + :return: True if index is valid, False otherwise + """ + index_version = self.INDEX.get(self._COMMAND_INDEX_VERSION) + cloud_profile = self.INDEX.get(self._COMMAND_INDEX_CLOUD_PROFILE) + return (index_version and index_version == self.version and + cloud_profile and cloud_profile == self.cloud_profile) + def _get_top_level_completion_commands(self): """Get top-level command names for tab completion optimization. @@ -631,10 +778,7 @@ """ # If the command index version or cloud profile doesn't match those of the current command, # invalidate the command index. - index_version = self.INDEX[self._COMMAND_INDEX_VERSION] - cloud_profile = self.INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] - if not (index_version and index_version == self.version and - cloud_profile and cloud_profile == self.cloud_profile): + if not self._is_index_valid(): logger.debug("Command index version or cloud profile is invalid or doesn't match the current command.") self.invalidate() return None @@ -681,6 +825,28 @@ return None + def get_help_index(self): + """Get the help index for top-level help display. + + :return: Dictionary mapping top-level commands to their short summaries, or None if not available + """ + if not self._is_index_valid(): + return None + + help_index = self.INDEX.get(self._HELP_INDEX, {}) + if help_index: + logger.debug("Using cached help index with %d entries", len(help_index)) + return help_index + + return None + + def set_help_index(self, help_data): + """Set the help index data. + + :param help_data: Help index data structure containing groups and commands + """ + self.INDEX[self._HELP_INDEX] = help_data + def update(self, command_table): """Update the command index according to the given command table. @@ -700,6 +866,7 @@ module_name = command.loader.__module__ if module_name not in index[top_command]: index[top_command].append(module_name) + elapsed_time = timeit.default_timer() - start_time self.INDEX[self._COMMAND_INDEX] = index logger.debug("Updated command index in %.3f seconds.", elapsed_time) @@ -718,6 +885,7 @@ self.INDEX[self._COMMAND_INDEX_VERSION] = "" self.INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" self.INDEX[self._COMMAND_INDEX] = {} + self.INDEX[self._HELP_INDEX] = {} logger.debug("Command index has been invalidated.") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/azure_cli_core-2.83.0/azure/cli/core/_help.py new/azure_cli_core-2.84.0/azure/cli/core/_help.py --- old/azure_cli_core-2.83.0/azure/cli/core/_help.py 2026-01-27 08:23:53.000000000 +0100 +++ new/azure_cli_core-2.84.0/azure/cli/core/_help.py 2026-02-25 03:35:23.000000000 +0100 @@ -46,6 +46,73 @@ """ +def _get_tag_plain_text(tag_obj): + """Extract plain text from a tag object (typically ColorizedString). + + ColorizedString objects store plain text in _message and add ANSI codes via __str__. + For caching, we need plain text only. This function safely extracts it. + + :param tag_obj: Tag object (ColorizedString or other) + :return: Plain text string without ANSI codes + """ + # ColorizedString stores plain text in _message attribute + if hasattr(tag_obj, '_message'): + return tag_obj._message # pylint: disable=protected-access + # Fallback for non-ColorizedString objects + return str(tag_obj) + + +def get_help_item_tags(item): + """Extract status tags from a help item (group or command). + + Returns a space-separated string of plain text tags like '[Deprecated] [Preview]'. + """ + tags = [] + if hasattr(item, 'deprecate_info') and item.deprecate_info: + tag_obj = item.deprecate_info.tag + tags.append(_get_tag_plain_text(tag_obj)) + if hasattr(item, 'preview_info') and item.preview_info: + tag_obj = item.preview_info.tag + tags.append(_get_tag_plain_text(tag_obj)) + if hasattr(item, 'experimental_info') and item.experimental_info: + tag_obj = item.experimental_info.tag + tags.append(_get_tag_plain_text(tag_obj)) + return ' '.join(tags) + + +def extract_help_index_data(help_file): + """Extract groups and commands from help file children for caching. + + Processes help file children and builds dictionaries of groups and commands + with their summaries and tags for top-level help display. + + :param help_file: Help file with loaded children + :return: Tuple of (groups_dict, commands_dict) + """ + groups = {} + commands = {} + + for child in help_file.children: + if hasattr(child, 'name') and hasattr(child, 'short_summary'): + child_name = child.name + # Only include top-level items (no spaces in name) + if ' ' in child_name: + continue + + tags = get_help_item_tags(child) + item_data = { + 'summary': child.short_summary, + 'tags': tags + } + + if child.type == 'group': + groups[child_name] = item_data + else: + commands[child_name] = item_data + + return groups, commands + + # PrintMixin class to decouple printing functionality from AZCLIHelp class. # Most of these methods override print methods in CLIHelp class CLIPrintMixin(CLIHelp): @@ -241,6 +308,107 @@ def update_examples(help_file): pass + @staticmethod + def _colorize_tag(tag_text, enable_color): + """Add color to a plain text tag based on its content.""" + if not enable_color or not tag_text: + return tag_text + + from knack.util import color_map + + tag_lower = tag_text.lower() + if 'preview' in tag_lower: + color = color_map['preview'] + elif 'experimental' in tag_lower: + color = color_map['experimental'] + elif 'deprecat' in tag_lower: + color = color_map['deprecation'] + else: + return tag_text + + return f"{color}{tag_text}{color_map['reset']}" + + @staticmethod + def _build_cached_help_items(data, enable_color=False): + """Process help items from cache and return list with calculated line lengths.""" + from knack.help import _get_line_len + items = [] + for name in sorted(data.keys()): + item = data[name] + plain_tags = item.get('tags', '') + + # Colorize each tag individually if needed + if plain_tags and enable_color: + # Split multiple tags and colorize each + tag_parts = plain_tags.split() + colored_tags = ' '.join(AzCliHelp._colorize_tag(tag, enable_color) for tag in tag_parts) + else: + colored_tags = plain_tags + + tags_len = len(plain_tags) + line_len = _get_line_len(name, tags_len) + items.append((name, colored_tags, line_len, item.get('summary', ''))) + return items + + @staticmethod + def _print_cached_help_section(items, header, max_line_len): + """Display cached help items with consistent formatting.""" + from knack.help import FIRST_LINE_PREFIX, _get_hanging_indent, _get_padding_len + if not items: + return + print(f"\n{header}") + indent = 1 + LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' + for name, tags, line_len, summary in items: + layout = {'line_len': line_len, 'tags': tags} + padding = ' ' * _get_padding_len(max_line_len, layout) + line = LINE_FORMAT.format( + name=name, + padding=padding, + tags=tags, + separator=FIRST_LINE_PREFIX if summary else '', + summary=summary + ) + _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) + + def show_cached_help(self, help_data, args=None): + """Display help from cached help index without loading modules. + + Args: + help_data: Cached help data dictionary + args: Original command line args. If empty/None, shows welcome banner. + """ + ran_before = self.cli_ctx.config.getboolean('core', 'first_run', fallback=False) + if not ran_before: + print(PRIVACY_STATEMENT) + self.cli_ctx.config.set_value('core', 'first_run', 'yes') + + if not args: + print(WELCOME_MESSAGE) + + print("\nGroup") + print(" az") + + groups_data = help_data.get('groups', {}) + commands_data = help_data.get('commands', {}) + + groups_items = self._build_cached_help_items(groups_data, self.cli_ctx.enable_color) + commands_items = self._build_cached_help_items(commands_data, self.cli_ctx.enable_color) + max_line_len = max( + (line_len for _, _, line_len, _ in groups_items + commands_items), + default=0 + ) + + self._print_cached_help_section(groups_items, "Subgroups:", max_line_len) + self._print_cached_help_section(commands_items, "Commands:", max_line_len) + + # Use same az find message as non-cached path + print() # Blank line before the message + self._print_az_find_message('') + + from azure.cli.core.util import show_updates_available + show_updates_available(new_line_after=True) + class CliHelpFile(KnackHelpFile): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/azure_cli_core-2.83.0/azure/cli/core/commands/__init__.py new/azure_cli_core-2.84.0/azure/cli/core/commands/__init__.py --- old/azure_cli_core-2.83.0/azure/cli/core/commands/__init__.py 2026-01-27 08:23:53.000000000 +0100 +++ new/azure_cli_core-2.84.0/azure/cli/core/commands/__init__.py 2026-02-25 03:35:23.000000000 +0100 @@ -518,6 +518,11 @@ command_preserve_casing = roughly_parse_command_with_casing(args) args = _pre_command_table_create(self.cli_ctx, args) + if self._should_show_cached_help(args): + result = self._try_show_cached_help(command_preserve_casing, args) + if result: + return result + self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) self.commands_loader.load_command_table(args) self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, @@ -579,6 +584,14 @@ subparser = self.parser.subparsers[tuple()] self.help.show_welcome(subparser) + use_command_index = self._should_use_command_index() + logger.debug("About to cache help data, use_command_index=%s", use_command_index) + if use_command_index: + try: + self._save_help_to_command_index(subparser) + except Exception as ex: # pylint: disable=broad-except + logger.debug("Failed to cache help data: %s", ex) + # TODO: No event in base with which to target telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) telemetry.set_success(summary='welcome') @@ -700,6 +713,68 @@ return [(p.split('=', 1)[0] if p.startswith('--') else p[:2]) for p in args if (p.startswith('-') and not p.startswith('---') and len(p) > 1)] + @staticmethod + def _is_top_level_help_request(args): + """Determine if this is a top-level help request (az --help or just az). + + Returns True for both 'az' with no args and 'az --help' so we can use + cached data without loading all modules. + """ + if not args: + return True + + for arg in args: + if arg in ('--help', '-h', 'help'): + return True + if not arg.startswith('-'): + return False + + return False + + def _should_use_command_index(self): + """Check if command index optimization is enabled.""" + return self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) + + def _should_show_cached_help(self, args): + return (self._should_use_command_index() and + self._is_top_level_help_request(args) and + not self.cli_ctx.data.get('completer_active')) + + def _try_show_cached_help(self, command_preserve_casing, args): + """Try to show cached help for top-level help request. + + Returns CommandResultItem if cached help was shown, None otherwise. + """ + from azure.cli.core import CommandIndex + command_index = CommandIndex(self.cli_ctx) + help_index = command_index.get_help_index() + + if help_index: + # Display cached help using the help system + self.help.show_cached_help(help_index, args) + telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing, parameters=['--help']) + telemetry.set_success(summary='show help') + return CommandResultItem(None, exit_code=0) + + return None + + def _save_help_to_command_index(self, subparser): + """Extract help data from parser and save to command index for future fast access.""" + from azure.cli.core import CommandIndex + from azure.cli.core._help import CliGroupHelpFile, extract_help_index_data + + command_index = CommandIndex(self.cli_ctx) + help_file = CliGroupHelpFile(self.help, '', subparser) + help_file.load(subparser) + + groups, commands = extract_help_index_data(help_file) + + # Store in the command index + help_index_data = {'groups': groups, 'commands': commands} + if groups or commands: + command_index.set_help_index(help_index_data) + logger.debug("Cached %d groups and %d commands for fast access", len(groups), len(commands)) + def _run_job(self, expanded_arg, cmd_copy): params = self._filter_params(expanded_arg) try: @@ -1134,22 +1209,17 @@ logger.debug("Module '%s' is missing `get_command_loader` entry.", name) command_table = {} + command_loader = None if loader_cls: command_loader = loader_cls(cli_ctx=loader.cli_ctx) - loader.loaders.append(command_loader) # This will be used by interactive if command_loader.supported_resource_type(): command_table = command_loader.load_command_table(args) - if command_table: - for cmd in list(command_table.keys()): - # TODO: If desired to for extension to patch module, this can be uncommented - # if loader.cmd_to_loader_map.get(cmd): - # loader.cmd_to_loader_map[cmd].append(command_loader) - # else: - loader.cmd_to_loader_map[cmd] = [command_loader] else: logger.debug("Module '%s' is missing `COMMAND_LOADER_CLS` entry.", name) - return command_table, command_loader.command_group_table + + group_table = command_loader.command_group_table if command_loader else {} + return command_table, group_table, command_loader def _load_extension_command_loader(loader, args, ext): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/azure_cli_core-2.83.0/azure/cli/core/parser.py new/azure_cli_core-2.84.0/azure/cli/core/parser.py --- old/azure_cli_core-2.83.0/azure/cli/core/parser.py 2026-01-27 08:23:53.000000000 +0100 +++ new/azure_cli_core-2.84.0/azure/cli/core/parser.py 2026-02-25 03:35:23.000000000 +0100 @@ -186,6 +186,7 @@ telemetry.set_command_details( command=self.prog[3:], + parameters=['--help'], extension_name=extension_name, extension_version=extension_version) telemetry.set_success(summary='show help') diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/azure_cli_core-2.83.0/azure/cli/core/profiles/_shared.py new/azure_cli_core-2.84.0/azure/cli/core/profiles/_shared.py --- old/azure_cli_core-2.83.0/azure/cli/core/profiles/_shared.py 2026-01-27 08:23:53.000000000 +0100 +++ new/azure_cli_core-2.84.0/azure/cli/core/profiles/_shared.py 2026-02-25 03:35:23.000000000 +0100 @@ -218,7 +218,7 @@ ResourceType.MGMT_IOTHUB: None, ResourceType.MGMT_IOTDPS: None, ResourceType.MGMT_IOTCENTRAL: None, - ResourceType.MGMT_ARO: '2023-11-22', + ResourceType.MGMT_ARO: None, ResourceType.MGMT_DATABOXEDGE: '2021-02-01-preview', ResourceType.MGMT_CUSTOMLOCATION: '2021-03-15-preview', ResourceType.MGMT_CONTAINERSERVICE: None, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/azure_cli_core-2.83.0/azure_cli_core.egg-info/PKG-INFO new/azure_cli_core-2.84.0/azure_cli_core.egg-info/PKG-INFO --- old/azure_cli_core-2.83.0/azure_cli_core.egg-info/PKG-INFO 2026-01-27 08:24:42.000000000 +0100 +++ new/azure_cli_core-2.84.0/azure_cli_core.egg-info/PKG-INFO 2026-02-25 03:36:22.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: azure-cli-core -Version: 2.83.0 +Version: 2.84.0 Summary: Microsoft Azure Command-Line Tools Core Module Home-page: https://github.com/Azure/azure-cli Author: Microsoft Corporation diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/azure_cli_core-2.83.0/setup.py new/azure_cli_core-2.84.0/setup.py --- old/azure_cli_core-2.83.0/setup.py 2026-01-27 08:23:53.000000000 +0100 +++ new/azure_cli_core-2.84.0/setup.py 2026-02-25 03:35:23.000000000 +0100 @@ -8,7 +8,7 @@ from codecs import open from setuptools import setup, find_packages -VERSION = "2.83.0" +VERSION = "2.84.0" # If we have source, validate that our version numbers match # This should prevent uploading releases with mismatched versions.
