From f37bb162551907b847510c00826b404591ed5647 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 13 Jan 2026 16:33:30 +1100 Subject: [PATCH 01/38] feature: caching top-level help --- src/azure-cli-core/azure/cli/core/__init__.py | 103 ++++++++++++++++++ .../azure/cli/core/commands/__init__.py | 29 +++++ 2 files changed, 132 insertions(+) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 16396007d95..8287d112461 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -452,6 +452,20 @@ def _get_extension_suppressions(mod_loaders): 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) + + # Fast path for top-level help (az --help or az with no args) + # Check if we can use cached help index to skip module loading + if use_command_index and (not args or args[0] in ('--help', '-h', 'help')): + command_index = CommandIndex(self.cli_ctx) + help_index = command_index.get_help_index() + if help_index: + logger.debug("Using cached help index, skipping module loading") + # Display help directly from cached data without loading modules + self._display_cached_help(help_index) + # Raise SystemExit to stop execution (similar to how --help normally works) + import sys + sys.exit(0) + if use_command_index: command_index = CommandIndex(self.cli_ctx) index_result = command_index.get(args) @@ -525,8 +539,72 @@ def _get_extension_suppressions(mod_loaders): if use_command_index: command_index.update(self.command_table) + + # Also cache help data for fast az --help in future + # This is done after loading all modules when help data is available + self._cache_help_index(command_index) return self.command_table + + def _display_cached_help(self, help_index): + """Display help from cached help index without loading modules.""" + from azure.cli.core._help import WELCOME_MESSAGE, PRIVACY_STATEMENT + + # Show privacy statement if first run + 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') + + # Show welcome message + print(WELCOME_MESSAGE) + + # Display subgroups from cached data + if help_index: + print("Subgroups:") + # Sort and display in the same format as normal help + max_name_len = max(len(name) for name in help_index.keys()) + for name in sorted(help_index.keys()): + summary = help_index[name] + padding = ' ' * (max_name_len - len(name)) + print(f" {name}{padding} : {summary}") + + print("\nTo search AI knowledge base for examples, use: az find \"az \"") + print("\nFor more specific examples, use: az find \"az \"") + + # Show update notification + from azure.cli.core.util import show_updates_available + show_updates_available(new_line_after=True) + + def _cache_help_index(self, command_index): + """Cache help summaries for top-level commands to speed up `az --help`.""" + try: + # Create a temporary parser to extract help information + from azure.cli.core.parser import AzCliCommandParser + parser = AzCliCommandParser(self.cli_ctx) + parser.load_command_table(self) + + # Get the help file for the root level + from azure.cli.core._help import CliGroupHelpFile + subparser = parser.subparsers.get(tuple()) + if subparser: + # Use cli_ctx.help which is the AzCliHelp instance + help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser) + help_file.load(subparser) + + # Extract summaries from help file's children + help_index_data = {} + for child in help_file.children: + if hasattr(child, 'name') and hasattr(child, 'short_summary'): + if ' ' not in child.name: # Only top-level commands + help_index_data[child.name] = child.short_summary + + # Store in the command index + if help_index_data: + command_index.INDEX[command_index._HELP_INDEX] = help_index_data + logger.debug("Cached %d help entries", len(help_index_data)) + except Exception as ex: # pylint: disable=broad-except + logger.debug("Failed to cache help data: %s", ex) @staticmethod def _sort_command_loaders(command_loaders): @@ -698,6 +776,7 @@ 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. @@ -786,6 +865,25 @@ def get(self, args): 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 + """ + # Check if index is valid + 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): + 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 update(self, command_table): """Update the command index according to the given command table. @@ -796,6 +894,7 @@ def update(self, command_table): self.INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = self.cloud_profile from collections import defaultdict index = defaultdict(list) + help_index = {} # Maps top-level command to short summary # self.cli_ctx.invocation.commands_loader.command_table doesn't exist in DummyCli due to the lack of invocation for command_name, command in command_table.items(): @@ -805,8 +904,11 @@ def update(self, command_table): 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 + # Note: helpIndex is populated separately when az --help is displayed + # We don't populate it here because the help data isn't available yet logger.debug("Updated command index in %.3f seconds.", elapsed_time) def invalidate(self): @@ -823,6 +925,7 @@ def invalidate(self): 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 --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 1764fbdf25e..2d705cc5e2d 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -578,6 +578,35 @@ def execute(self, args): self.parser.enable_autocomplete() subparser = self.parser.subparsers[tuple()] self.help.show_welcome(subparser) + + # After showing help, cache the help summaries for future fast access + # This allows subsequent `az --help` calls to skip module loading + use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) + logger.debug("About to cache help data, use_command_index=%s", use_command_index) + if use_command_index: + try: + from azure.cli.core import CommandIndex + command_index = CommandIndex(self.cli_ctx) + # Extract help data from the parser that was just used + from azure.cli.core._help import CliGroupHelpFile + help_file = CliGroupHelpFile(self.cli_ctx.help, '', subparser) + help_file.load(subparser) + + # Build help index from the help file's children + help_index_data = {} + for child in help_file.children: + if hasattr(child, 'name') and hasattr(child, 'short_summary'): + if ' ' not in child.name: # Only top-level commands + help_index_data[child.name] = child.short_summary + + # Store in the command index + if help_index_data: + from azure.cli.core._session import INDEX + from azure.cli.core import __version__ + INDEX['helpIndex'] = help_index_data + logger.debug("Cached %d help entries for fast access", len(help_index_data)) + 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) From fa0b15a7fbef600c82b837a52e4402bf78142969 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 13 Jan 2026 17:25:01 +1100 Subject: [PATCH 02/38] feature: adjustment help formatting --- src/azure-cli-core/azure/cli/core/__init__.py | 86 +++++++++++++++---- .../azure/cli/core/commands/__init__.py | 35 ++++++-- 2 files changed, 98 insertions(+), 23 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 8287d112461..dac6bea864e 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -463,7 +463,6 @@ def _get_extension_suppressions(mod_loaders): # Display help directly from cached data without loading modules self._display_cached_help(help_index) # Raise SystemExit to stop execution (similar to how --help normally works) - import sys sys.exit(0) if use_command_index: @@ -559,18 +558,48 @@ def _display_cached_help(self, help_index): # Show welcome message print(WELCOME_MESSAGE) - # Display subgroups from cached data - if help_index: - print("Subgroups:") - # Sort and display in the same format as normal help - max_name_len = max(len(name) for name in help_index.keys()) - for name in sorted(help_index.keys()): - summary = help_index[name] - padding = ' ' * (max_name_len - len(name)) - print(f" {name}{padding} : {summary}") + # Show Group header (to match normal help output) + print("\nGroup") + print(" az") + + # Separate groups and commands + groups = help_index.get('groups', {}) + commands = help_index.get('commands', {}) + + # Calculate max name length including tags for proper alignment + def _get_display_len(name, tags): + tag_len = len(tags) + 1 if tags else 0 # +1 for space before tags + return len(name) + tag_len + + max_len = 0 + if groups: + max_len = max(max_len, max(_get_display_len(name, item.get('tags', '')) for name, item in groups.items())) + if commands: + max_len = max(max_len, max(_get_display_len(name, item.get('tags', '')) for name, item in commands.items())) + + # Display subgroups + if groups: + print("\nSubgroups:") + for name in sorted(groups.keys()): + item = groups[name] + tags = item.get('tags', '') + summary = item.get('summary', '') + name_with_tags = f"{name} {tags}" if tags else name + padding = ' ' * (max_len - _get_display_len(name, tags)) + print(f" {name_with_tags}{padding} : {summary}") + + # Display commands + if commands: + print("\nCommands:") + for name in sorted(commands.keys()): + item = commands[name] + tags = item.get('tags', '') + summary = item.get('summary', '') + name_with_tags = f"{name} {tags}" if tags else name + padding = ' ' * (max_len - _get_display_len(name, tags)) + print(f" {name_with_tags}{padding} : {summary}") print("\nTo search AI knowledge base for examples, use: az find \"az \"") - print("\nFor more specific examples, use: az find \"az \"") # Show update notification from azure.cli.core.util import show_updates_available @@ -592,17 +621,40 @@ def _cache_help_index(self, command_index): help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser) help_file.load(subparser) - # Extract summaries from help file's children - help_index_data = {} + # Helper to build tag string for an item + def _get_tags(item): + tags = [] + if hasattr(item, 'deprecate_info') and item.deprecate_info: + tags.append(str(item.deprecate_info.tag)) + if hasattr(item, 'preview_info') and item.preview_info: + tags.append(str(item.preview_info.tag)) + if hasattr(item, 'experimental_info') and item.experimental_info: + tags.append(str(item.experimental_info.tag)) + return ' '.join(tags) + + # Separate groups and commands + groups = {} + commands = {} + for child in help_file.children: if hasattr(child, 'name') and hasattr(child, 'short_summary'): - if ' ' not in child.name: # Only top-level commands - help_index_data[child.name] = child.short_summary + if ' ' not in child.name: # Only top-level items + tags = _get_tags(child) + item_data = { + 'summary': child.short_summary, + 'tags': tags + } + # Check if it's a group or command + if child.type == 'group': + groups[child.name] = item_data + else: + commands[child.name] = item_data # Store in the command index - if help_index_data: + help_index_data = {'groups': groups, 'commands': commands} + if groups or commands: command_index.INDEX[command_index._HELP_INDEX] = help_index_data - logger.debug("Cached %d help entries", len(help_index_data)) + logger.debug("Cached %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) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 2d705cc5e2d..e6796e12be9 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -592,19 +592,42 @@ def execute(self, args): help_file = CliGroupHelpFile(self.cli_ctx.help, '', subparser) help_file.load(subparser) - # Build help index from the help file's children - help_index_data = {} + # Helper to build tag string for an item + def _get_tags(item): + tags = [] + if hasattr(item, 'deprecate_info') and item.deprecate_info: + tags.append(str(item.deprecate_info.tag)) + if hasattr(item, 'preview_info') and item.preview_info: + tags.append(str(item.preview_info.tag)) + if hasattr(item, 'experimental_info') and item.experimental_info: + tags.append(str(item.experimental_info.tag)) + return ' '.join(tags) + + # Separate groups and commands + groups = {} + commands = {} + for child in help_file.children: if hasattr(child, 'name') and hasattr(child, 'short_summary'): - if ' ' not in child.name: # Only top-level commands - help_index_data[child.name] = child.short_summary + if ' ' not in child.name: # Only top-level items + tags = _get_tags(child) + item_data = { + 'summary': child.short_summary, + 'tags': tags + } + # Check if it's a group or command + if child.type == 'group': + groups[child.name] = item_data + else: + commands[child.name] = item_data # Store in the command index - if help_index_data: + help_index_data = {'groups': groups, 'commands': commands} + if groups or commands: from azure.cli.core._session import INDEX from azure.cli.core import __version__ INDEX['helpIndex'] = help_index_data - logger.debug("Cached %d help entries for fast access", len(help_index_data)) + logger.debug("Cached %d groups and %d commands for fast access", len(groups), len(commands)) except Exception as ex: # pylint: disable=broad-except logger.debug("Failed to cache help data: %s", ex) From 1316bddec8f1fb59d902d59463f08f81889acea2 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 14 Jan 2026 11:06:14 +1100 Subject: [PATCH 03/38] fix: adjust help printout formatting --- src/azure-cli-core/azure/cli/core/__init__.py | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index dac6bea864e..e98fd134f48 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -548,6 +548,12 @@ def _get_extension_suppressions(mod_loaders): def _display_cached_help(self, help_index): """Display help from cached help index without loading modules.""" from azure.cli.core._help import WELCOME_MESSAGE, PRIVACY_STATEMENT + import re + + def _strip_ansi(text): + """Remove ANSI color codes from text for length calculation.""" + ansi_escape = re.compile(r'\x1b\[[0-9;]*m') + return ansi_escape.sub('', text) # Show privacy statement if first run ran_before = self.cli_ctx.config.getboolean('core', 'first_run', fallback=False) @@ -566,38 +572,49 @@ def _display_cached_help(self, help_index): groups = help_index.get('groups', {}) commands = help_index.get('commands', {}) - # Calculate max name length including tags for proper alignment - def _get_display_len(name, tags): - tag_len = len(tags) + 1 if tags else 0 # +1 for space before tags - return len(name) + tag_len - - max_len = 0 - if groups: - max_len = max(max_len, max(_get_display_len(name, item.get('tags', '')) for name, item in groups.items())) - if commands: - max_len = max(max_len, max(_get_display_len(name, item.get('tags', '')) for name, item in commands.items())) - # Display subgroups if groups: print("\nSubgroups:") + # Calculate max line length for groups only (matching knack's logic) + max_len = 0 + for name, item in groups.items(): + tags = item.get('tags', '') + tags_len = len(_strip_ansi(tags)) + line_len = len(name) + tags_len + (2 if tags_len else 1) + max_len = max(max_len, line_len) + for name in sorted(groups.keys()): item = groups[name] tags = item.get('tags', '') summary = item.get('summary', '') - name_with_tags = f"{name} {tags}" if tags else name - padding = ' ' * (max_len - _get_display_len(name, tags)) - print(f" {name_with_tags}{padding} : {summary}") + # Calculate padding (matching knack's _get_padding_len logic) + tags_len = len(_strip_ansi(tags)) + line_len = len(name) + tags_len + (2 if tags_len else 1) + pad_len = max_len - line_len + (1 if tags else 0) + padding = ' ' * pad_len + # Format matches knack: name + padding + tags + " : " + summary + print(f" {name}{padding}{tags} : {summary}") # Display commands if commands: print("\nCommands:") + # Calculate max line length for commands only + max_len = 0 + for name, item in commands.items(): + tags = item.get('tags', '') + tags_len = len(_strip_ansi(tags)) + line_len = len(name) + tags_len + (2 if tags_len else 1) + max_len = max(max_len, line_len) + for name in sorted(commands.keys()): item = commands[name] tags = item.get('tags', '') summary = item.get('summary', '') - name_with_tags = f"{name} {tags}" if tags else name - padding = ' ' * (max_len - _get_display_len(name, tags)) - print(f" {name_with_tags}{padding} : {summary}") + tags_len = len(_strip_ansi(tags)) + line_len = len(name) + tags_len + (2 if tags_len else 1) + pad_len = max_len - line_len + (1 if tags else 0) + padding = ' ' * pad_len + print(f" {name}{padding}{tags} : {summary}") print("\nTo search AI knowledge base for examples, use: az find \"az \"") From e690bb1771fbb30bafe2d6343a8646d264e28106 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 14 Jan 2026 11:43:12 +1100 Subject: [PATCH 04/38] fix: adjust alignment of printout --- src/azure-cli-core/azure/cli/core/__init__.py | 105 +++++++++++------- 1 file changed, 63 insertions(+), 42 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index e98fd134f48..34ec414d935 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -564,57 +564,78 @@ def _strip_ansi(text): # Show welcome message print(WELCOME_MESSAGE) - # Show Group header (to match normal help output) print("\nGroup") print(" az") + # Import knack's formatting functions + from knack.help import _print_indent, FIRST_LINE_PREFIX, _get_hanging_indent + # Separate groups and commands - groups = help_index.get('groups', {}) - commands = help_index.get('commands', {}) + groups_data = help_index.get('groups', {}) + commands_data = help_index.get('commands', {}) + + # Helper function matching knack's _get_line_len + def _get_line_len(name, tags): + tags_len = len(_strip_ansi(tags)) + return len(name) + tags_len + (2 if tags_len else 1) + + # Helper function matching knack's _get_padding_len + def _get_padding_len(max_len, name, tags): + line_len = _get_line_len(name, tags) + if tags: + pad_len = max_len - line_len + 1 + else: + pad_len = max_len - line_len + return pad_len - # Display subgroups - if groups: + # Build items lists and calculate max_line_len across ALL items (groups + commands) + # This ensures colons align across both sections + max_line_len = 0 + groups_items = [] + for name in sorted(groups_data.keys()): + item = groups_data[name] + tags = item.get('tags', '') + groups_items.append((name, tags, item.get('summary', ''))) + max_line_len = max(max_line_len, _get_line_len(name, tags)) + + commands_items = [] + for name in sorted(commands_data.keys()): + item = commands_data[name] + tags = item.get('tags', '') + commands_items.append((name, tags, item.get('summary', ''))) + max_line_len = max(max_line_len, _get_line_len(name, tags)) + + # Display groups + if groups_items: print("\nSubgroups:") - # Calculate max line length for groups only (matching knack's logic) - max_len = 0 - for name, item in groups.items(): - tags = item.get('tags', '') - tags_len = len(_strip_ansi(tags)) - line_len = len(name) + tags_len + (2 if tags_len else 1) - max_len = max(max_len, line_len) - - for name in sorted(groups.keys()): - item = groups[name] - tags = item.get('tags', '') - summary = item.get('summary', '') - # Calculate padding (matching knack's _get_padding_len logic) - tags_len = len(_strip_ansi(tags)) - line_len = len(name) + tags_len + (2 if tags_len else 1) - pad_len = max_len - line_len + (1 if tags else 0) - padding = ' ' * pad_len - # Format matches knack: name + padding + tags + " : " + summary - print(f" {name}{padding}{tags} : {summary}") + indent = 1 + LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' + for name, tags, summary in groups_items: + padding = ' ' * _get_padding_len(max_line_len, name, tags) + 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)) # Display commands - if commands: + if commands_items: print("\nCommands:") - # Calculate max line length for commands only - max_len = 0 - for name, item in commands.items(): - tags = item.get('tags', '') - tags_len = len(_strip_ansi(tags)) - line_len = len(name) + tags_len + (2 if tags_len else 1) - max_len = max(max_len, line_len) - - for name in sorted(commands.keys()): - item = commands[name] - tags = item.get('tags', '') - summary = item.get('summary', '') - tags_len = len(_strip_ansi(tags)) - line_len = len(name) + tags_len + (2 if tags_len else 1) - pad_len = max_len - line_len + (1 if tags else 0) - padding = ' ' * pad_len - print(f" {name}{padding}{tags} : {summary}") + indent = 1 + LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' + for name, tags, summary in commands_items: + padding = ' ' * _get_padding_len(max_line_len, name, tags) + 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)) print("\nTo search AI knowledge base for examples, use: az find \"az \"") From dac5a15a10bdc089007e7b2947cad28ca50c80ff Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 21 Jan 2026 15:57:36 +1100 Subject: [PATCH 05/38] feature: extend concept to module/command level help --- src/azure-cli-core/azure/cli/core/__init__.py | 179 +++++++++++++----- 1 file changed, 132 insertions(+), 47 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 34ec414d935..c3d4d77e54c 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -453,17 +453,34 @@ def _get_extension_suppressions(mod_loaders): # 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) - # Fast path for top-level help (az --help or az with no args) - # Check if we can use cached help index to skip module loading - if use_command_index and (not args or args[0] in ('--help', '-h', 'help')): + # Fast path for help requests - check if we can use cached help index to skip module loading + if use_command_index and args and '--help' in args or '-h' in args or (args and args[-1] == 'help'): + # Parse the command path from args (e.g., ['vm', '--help'] -> 'vm') + command_path_parts = [] + for arg in args: + if arg in ('--help', '-h', 'help'): + break + if not arg.startswith('-'): + command_path_parts.append(arg) + + command_path = ' '.join(command_path_parts) if command_path_parts else 'root' + command_index = CommandIndex(self.cli_ctx) help_index = command_index.get_help_index() - if help_index: - logger.debug("Using cached help index, skipping module loading") + if help_index and command_path in help_index: + logger.debug("Using cached help index for '%s', skipping module loading", command_path) # Display help directly from cached data without loading modules - self._display_cached_help(help_index) + self._display_cached_help(help_index[command_path], command_path) # Raise SystemExit to stop execution (similar to how --help normally works) sys.exit(0) + # Fast path for top-level with no args (az with no arguments) + elif use_command_index and not args: + command_index = CommandIndex(self.cli_ctx) + help_index = command_index.get_help_index() + if help_index and 'root' in help_index: + logger.debug("Using cached help index for root, skipping module loading") + self._display_cached_help(help_index['root'], 'root') + sys.exit(0) if use_command_index: command_index = CommandIndex(self.cli_ctx) @@ -545,7 +562,7 @@ def _get_extension_suppressions(mod_loaders): return self.command_table - def _display_cached_help(self, help_index): + def _display_cached_help(self, help_data, command_path='root'): """Display help from cached help index without loading modules.""" from azure.cli.core._help import WELCOME_MESSAGE, PRIVACY_STATEMENT import re @@ -564,15 +581,20 @@ def _strip_ansi(text): # Show welcome message print(WELCOME_MESSAGE) - print("\nGroup") - print(" az") + # Display the group breadcrumb + if command_path == 'root': + print("\nGroup") + print(" az") + else: + print("\nGroup") + print(f" az {command_path}") # Import knack's formatting functions from knack.help import _print_indent, FIRST_LINE_PREFIX, _get_hanging_indent # Separate groups and commands - groups_data = help_index.get('groups', {}) - commands_data = help_index.get('commands', {}) + groups_data = help_data.get('groups', {}) + commands_data = help_data.get('commands', {}) # Helper function matching knack's _get_line_len def _get_line_len(name, tags): @@ -644,55 +666,118 @@ def _get_padding_len(max_len, name, tags): show_updates_available(new_line_after=True) def _cache_help_index(self, command_index): - """Cache help summaries for top-level commands to speed up `az --help`.""" + """Cache help summaries for all commands/groups recursively using parallel processing.""" try: - # Create a temporary parser to extract help information + import concurrent.futures + from concurrent.futures import ThreadPoolExecutor from azure.cli.core.parser import AzCliCommandParser + from azure.cli.core._help import CliGroupHelpFile + + # Create a temporary parser to extract help information parser = AzCliCommandParser(self.cli_ctx) parser.load_command_table(self) - # Get the help file for the root level - from azure.cli.core._help import CliGroupHelpFile - subparser = parser.subparsers.get(tuple()) - if subparser: - # Use cli_ctx.help which is the AzCliHelp instance - help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser) - help_file.load(subparser) - - # Helper to build tag string for an item - def _get_tags(item): - tags = [] - if hasattr(item, 'deprecate_info') and item.deprecate_info: - tags.append(str(item.deprecate_info.tag)) - if hasattr(item, 'preview_info') and item.preview_info: - tags.append(str(item.preview_info.tag)) - if hasattr(item, 'experimental_info') and item.experimental_info: - tags.append(str(item.experimental_info.tag)) - return ' '.join(tags) - - # Separate groups and commands - groups = {} - commands = {} - - for child in help_file.children: - if hasattr(child, 'name') and hasattr(child, 'short_summary'): - if ' ' not in child.name: # Only top-level items + # Helper to build tag string for an item + def _get_tags(item): + tags = [] + if hasattr(item, 'deprecate_info') and item.deprecate_info: + tags.append(str(item.deprecate_info.tag)) + if hasattr(item, 'preview_info') and item.preview_info: + tags.append(str(item.preview_info.tag)) + if hasattr(item, 'experimental_info') and item.experimental_info: + tags.append(str(item.experimental_info.tag)) + return ' '.join(tags) + + # Function to cache help for a single group + def _cache_single_group_help(group_path, subparser): + """Cache help for a single group and return its data along with subgroups to process.""" + try: + help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, group_path, subparser) + help_file.load(subparser) + + groups = {} + commands = {} + subgroup_names = [] + + for child in help_file.children: + if hasattr(child, 'name') and hasattr(child, 'short_summary'): + # Extract just the last part of the name (after the group path) + child_name = child.name + if group_path and child_name.startswith(group_path + ' '): + child_name = child_name[len(group_path) + 1:] + elif not group_path and ' ' in child_name: + # Skip nested items at root level + continue + tags = _get_tags(child) item_data = { 'summary': child.short_summary, 'tags': tags } - # Check if it's a group or command + if child.type == 'group': - groups[child.name] = item_data + groups[child_name] = item_data + # Build full path for recursion + if group_path: + full_subgroup_name = f"{group_path} {child_name}" + else: + full_subgroup_name = child_name + subgroup_names.append(full_subgroup_name) else: - commands[child.name] = item_data + commands[child_name] = item_data + + level_key = group_path if group_path else 'root' + level_data = None + if groups or commands: + level_data = {'groups': groups, 'commands': commands} + + return level_key, level_data, subgroup_names - # Store in the command index - help_index_data = {'groups': groups, 'commands': commands} - if groups or commands: - command_index.INDEX[command_index._HELP_INDEX] = help_index_data - logger.debug("Cached %d groups and %d commands", len(groups), len(commands)) + except Exception as ex: # pylint: disable=broad-except + logger.debug("Failed to cache help for '%s': %s", group_path, ex) + return None, None, [] + + # Build help index using BFS with parallel processing at each level + help_index_data = {} + to_process = [('', parser.subparsers.get(tuple()))] # (group_path, subparser) tuples + + while to_process: + # Process current level in parallel + with ThreadPoolExecutor(max_workers=4) as executor: + futures = {} + for group_path, subparser in to_process: + if subparser: + future = executor.submit(_cache_single_group_help, group_path, subparser) + futures[future] = group_path + + next_level = [] + for future in concurrent.futures.as_completed(futures): + try: + level_key, level_data, subgroup_names = future.result(timeout=10) + if level_data: + help_index_data[level_key] = level_data + + # Queue subgroups for next level + for subgroup_name in subgroup_names: + subgroup_tuple = tuple(subgroup_name.split()) + sub_subparser = parser.subparsers.get(subgroup_tuple) + if sub_subparser: + next_level.append((subgroup_name, sub_subparser)) + + except concurrent.futures.TimeoutError: + group_path = futures[future] + logger.debug("Help caching timeout for '%s'", group_path) + except Exception as ex: # pylint: disable=broad-except + group_path = futures[future] + logger.debug("Failed to cache help for '%s': %s", group_path, ex) + + to_process = next_level + + # Store the complete help index in one operation + if help_index_data: + command_index.INDEX[command_index._HELP_INDEX] = help_index_data + logger.debug("Cached help for %d command levels", len(help_index_data)) + except Exception as ex: # pylint: disable=broad-except logger.debug("Failed to cache help data: %s", ex) From 5cc633fbacfa89cbd465c87661f0404f7d025da4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 21 Jan 2026 16:38:27 +1100 Subject: [PATCH 06/38] feature: extend concept to nested commands --- src/azure-cli-core/azure/cli/core/__init__.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index c3d4d77e54c..f835226bf13 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -690,7 +690,7 @@ def _get_tags(item): # Function to cache help for a single group def _cache_single_group_help(group_path, subparser): - """Cache help for a single group and return its data along with subgroups to process.""" + """Cache help for a single group and return its data along with subgroups and commands to process.""" try: help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, group_path, subparser) help_file.load(subparser) @@ -698,6 +698,7 @@ def _cache_single_group_help(group_path, subparser): groups = {} commands = {} subgroup_names = [] + command_entries = [] # Store individual command cache entries for child in help_file.children: if hasattr(child, 'name') and hasattr(child, 'short_summary'): @@ -725,17 +726,27 @@ def _cache_single_group_help(group_path, subparser): subgroup_names.append(full_subgroup_name) else: commands[child_name] = item_data + # Create individual cache entry for this command + if group_path: + full_command_name = f"{group_path} {child_name}" + else: + full_command_name = child_name + # Store individual command with its full help data + command_entries.append((full_command_name, { + 'groups': {}, + 'commands': {child_name: item_data} + })) level_key = group_path if group_path else 'root' level_data = None if groups or commands: level_data = {'groups': groups, 'commands': commands} - return level_key, level_data, subgroup_names + return level_key, level_data, subgroup_names, command_entries except Exception as ex: # pylint: disable=broad-except logger.debug("Failed to cache help for '%s': %s", group_path, ex) - return None, None, [] + return None, None, [], [] # Build help index using BFS with parallel processing at each level help_index_data = {} @@ -753,10 +764,14 @@ def _cache_single_group_help(group_path, subparser): next_level = [] for future in concurrent.futures.as_completed(futures): try: - level_key, level_data, subgroup_names = future.result(timeout=10) + level_key, level_data, subgroup_names, command_entries = future.result(timeout=10) if level_data: help_index_data[level_key] = level_data + # Add individual command entries to cache + for cmd_key, cmd_data in command_entries: + help_index_data[cmd_key] = cmd_data + # Queue subgroups for next level for subgroup_name in subgroup_names: subgroup_tuple = tuple(subgroup_name.split()) From 0f08a37c2076d8db2682f1855910e7a74f82bea6 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 22 Jan 2026 12:18:16 +1100 Subject: [PATCH 07/38] fix: correct operator precedence in help fast path and remove no-args path - Fixed bug where 'args and '--help' in args or '-h' in args' evaluated incorrectly when args=None - Added parentheses to ensure proper grouping: 'args and ('--help' in args or '-h' in args ...)' - Removed 'elif not args' path that was incorrectly showing cached help when args=None - When args=None, load_command_table should load all commands, not display help - All test_help.py tests now pass (8/8) --- src/azure-cli-core/azure/cli/core/__init__.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index f835226bf13..6f33241dfdd 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -454,7 +454,7 @@ def _get_extension_suppressions(mod_loaders): use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) # Fast path for help requests - check if we can use cached help index to skip module loading - if use_command_index and args and '--help' in args or '-h' in args or (args and args[-1] == 'help'): + if use_command_index and args and ('--help' in args or '-h' in args or args[-1] == 'help'): # Parse the command path from args (e.g., ['vm', '--help'] -> 'vm') command_path_parts = [] for arg in args: @@ -474,14 +474,6 @@ def _get_extension_suppressions(mod_loaders): # Raise SystemExit to stop execution (similar to how --help normally works) sys.exit(0) # Fast path for top-level with no args (az with no arguments) - elif use_command_index and not args: - command_index = CommandIndex(self.cli_ctx) - help_index = command_index.get_help_index() - if help_index and 'root' in help_index: - logger.debug("Using cached help index for root, skipping module loading") - self._display_cached_help(help_index['root'], 'root') - sys.exit(0) - if use_command_index: command_index = CommandIndex(self.cli_ctx) index_result = command_index.get(args) @@ -1411,3 +1403,4 @@ def get_default_cli(): logging_cls=AzCliLogging, output_cls=AzOutputProducer, help_cls=AzCliHelp) + From 128ff24156a414f39b5bc03a3f4118d7ec50530c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 22 Jan 2026 12:22:52 +1100 Subject: [PATCH 08/38] Revert "feature: extend concept to nested commands" This reverts commit 3f5550820f2a139cfc388a3e477b49ac8581bba6. --- src/azure-cli-core/azure/cli/core/__init__.py | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 6f33241dfdd..871a2272231 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -682,7 +682,7 @@ def _get_tags(item): # Function to cache help for a single group def _cache_single_group_help(group_path, subparser): - """Cache help for a single group and return its data along with subgroups and commands to process.""" + """Cache help for a single group and return its data along with subgroups to process.""" try: help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, group_path, subparser) help_file.load(subparser) @@ -690,7 +690,6 @@ def _cache_single_group_help(group_path, subparser): groups = {} commands = {} subgroup_names = [] - command_entries = [] # Store individual command cache entries for child in help_file.children: if hasattr(child, 'name') and hasattr(child, 'short_summary'): @@ -718,27 +717,17 @@ def _cache_single_group_help(group_path, subparser): subgroup_names.append(full_subgroup_name) else: commands[child_name] = item_data - # Create individual cache entry for this command - if group_path: - full_command_name = f"{group_path} {child_name}" - else: - full_command_name = child_name - # Store individual command with its full help data - command_entries.append((full_command_name, { - 'groups': {}, - 'commands': {child_name: item_data} - })) level_key = group_path if group_path else 'root' level_data = None if groups or commands: level_data = {'groups': groups, 'commands': commands} - return level_key, level_data, subgroup_names, command_entries + return level_key, level_data, subgroup_names except Exception as ex: # pylint: disable=broad-except logger.debug("Failed to cache help for '%s': %s", group_path, ex) - return None, None, [], [] + return None, None, [] # Build help index using BFS with parallel processing at each level help_index_data = {} @@ -756,14 +745,10 @@ def _cache_single_group_help(group_path, subparser): next_level = [] for future in concurrent.futures.as_completed(futures): try: - level_key, level_data, subgroup_names, command_entries = future.result(timeout=10) + level_key, level_data, subgroup_names = future.result(timeout=10) if level_data: help_index_data[level_key] = level_data - # Add individual command entries to cache - for cmd_key, cmd_data in command_entries: - help_index_data[cmd_key] = cmd_data - # Queue subgroups for next level for subgroup_name in subgroup_names: subgroup_tuple = tuple(subgroup_name.split()) From 16d1c576eab4008d8b3070061fee1809830ac77d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 12 Feb 2026 12:11:22 +1100 Subject: [PATCH 09/38] refactor: remove module-level help caching --- src/azure-cli-core/azure/cli/core/__init__.py | 153 ++++++------------ 1 file changed, 51 insertions(+), 102 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 871a2272231..9b9fdbedaee 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -453,27 +453,35 @@ def _get_extension_suppressions(mod_loaders): # 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) - # Fast path for help requests - check if we can use cached help index to skip module loading + # Fast path for top-level help only (az --help or az with no args) + # Only use cache for root level, not for modules/commands if use_command_index and args and ('--help' in args or '-h' in args or args[-1] == 'help'): - # Parse the command path from args (e.g., ['vm', '--help'] -> 'vm') - command_path_parts = [] + # Check if this is top-level help request (no command path) + has_command_path = False for arg in args: if arg in ('--help', '-h', 'help'): break if not arg.startswith('-'): - command_path_parts.append(arg) - - command_path = ' '.join(command_path_parts) if command_path_parts else 'root' + has_command_path = True + break + # Only use cache for top-level help (no command arguments before --help) + if not has_command_path: + command_index = CommandIndex(self.cli_ctx) + help_index = command_index.get_help_index() + if help_index and 'root' in help_index: + logger.debug("Using cached help index for root, skipping module loading") + self._display_cached_help(help_index['root'], 'root') + sys.exit(0) + # Fast path for top-level with no args (az with no arguments) + elif use_command_index and not args: command_index = CommandIndex(self.cli_ctx) help_index = command_index.get_help_index() - if help_index and command_path in help_index: - logger.debug("Using cached help index for '%s', skipping module loading", command_path) - # Display help directly from cached data without loading modules - self._display_cached_help(help_index[command_path], command_path) - # Raise SystemExit to stop execution (similar to how --help normally works) + if help_index and 'root' in help_index: + logger.debug("Using cached help index for root, skipping module loading") + self._display_cached_help(help_index['root'], 'root') sys.exit(0) - # Fast path for top-level with no args (az with no arguments) + if use_command_index: command_index = CommandIndex(self.cli_ctx) index_result = command_index.get(args) @@ -658,10 +666,8 @@ def _get_padding_len(max_len, name, tags): show_updates_available(new_line_after=True) def _cache_help_index(self, command_index): - """Cache help summaries for all commands/groups recursively using parallel processing.""" + """Cache help summary for top-level (root) help only.""" try: - import concurrent.futures - from concurrent.futures import ThreadPoolExecutor from azure.cli.core.parser import AzCliCommandParser from azure.cli.core._help import CliGroupHelpFile @@ -680,95 +686,38 @@ def _get_tags(item): tags.append(str(item.experimental_info.tag)) return ' '.join(tags) - # Function to cache help for a single group - def _cache_single_group_help(group_path, subparser): - """Cache help for a single group and return its data along with subgroups to process.""" - try: - help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, group_path, subparser) - help_file.load(subparser) - - groups = {} - commands = {} - subgroup_names = [] - - for child in help_file.children: - if hasattr(child, 'name') and hasattr(child, 'short_summary'): - # Extract just the last part of the name (after the group path) - child_name = child.name - if group_path and child_name.startswith(group_path + ' '): - child_name = child_name[len(group_path) + 1:] - elif not group_path and ' ' in child_name: - # Skip nested items at root level - continue - - tags = _get_tags(child) - item_data = { - 'summary': child.short_summary, - 'tags': tags - } - - if child.type == 'group': - groups[child_name] = item_data - # Build full path for recursion - if group_path: - full_subgroup_name = f"{group_path} {child_name}" - else: - full_subgroup_name = child_name - subgroup_names.append(full_subgroup_name) - else: - commands[child_name] = item_data - - level_key = group_path if group_path else 'root' - level_data = None - if groups or commands: - level_data = {'groups': groups, 'commands': commands} - - return level_key, level_data, subgroup_names + # Only cache root level help + subparser = parser.subparsers.get(tuple()) + if subparser: + help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser) + help_file.load(subparser) - except Exception as ex: # pylint: disable=broad-except - logger.debug("Failed to cache help for '%s': %s", group_path, ex) - return None, None, [] - - # Build help index using BFS with parallel processing at each level - help_index_data = {} - to_process = [('', parser.subparsers.get(tuple()))] # (group_path, subparser) tuples - - while to_process: - # Process current level in parallel - with ThreadPoolExecutor(max_workers=4) as executor: - futures = {} - for group_path, subparser in to_process: - if subparser: - future = executor.submit(_cache_single_group_help, group_path, subparser) - futures[future] = group_path - - next_level = [] - for future in concurrent.futures.as_completed(futures): - try: - level_key, level_data, subgroup_names = future.result(timeout=10) - if level_data: - help_index_data[level_key] = level_data - - # Queue subgroups for next level - for subgroup_name in subgroup_names: - subgroup_tuple = tuple(subgroup_name.split()) - sub_subparser = parser.subparsers.get(subgroup_tuple) - if sub_subparser: - next_level.append((subgroup_name, sub_subparser)) + groups = {} + commands = {} + + for child in help_file.children: + if hasattr(child, 'name') and hasattr(child, 'short_summary'): + # Only include top-level items (no spaces in name) + child_name = child.name + if ' ' in child_name: + continue - except concurrent.futures.TimeoutError: - group_path = futures[future] - logger.debug("Help caching timeout for '%s'", group_path) - except Exception as ex: # pylint: disable=broad-except - group_path = futures[future] - logger.debug("Failed to cache help for '%s': %s", group_path, ex) - - to_process = next_level - - # Store the complete help index in one operation - if help_index_data: - command_index.INDEX[command_index._HELP_INDEX] = help_index_data - logger.debug("Cached help for %d command levels", len(help_index_data)) + tags = _get_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 + + # Store only root level help + if groups or commands: + help_index_data = {'root': {'groups': groups, 'commands': commands}} + command_index.INDEX[command_index._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) From 95200f663411980fb7a319baf56851d23da45bdb Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 12 Feb 2026 15:33:26 +1100 Subject: [PATCH 10/38] fix: fix top-level help to match test assertions --- src/azure-cli-core/azure/cli/core/__init__.py | 29 ----- .../azure/cli/core/commands/__init__.py | 102 ++++++++++++++++++ 2 files changed, 102 insertions(+), 29 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 9b9fdbedaee..0d40c1e3d5e 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -453,35 +453,6 @@ def _get_extension_suppressions(mod_loaders): # 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) - # Fast path for top-level help only (az --help or az with no args) - # Only use cache for root level, not for modules/commands - if use_command_index and args and ('--help' in args or '-h' in args or args[-1] == 'help'): - # Check if this is top-level help request (no command path) - has_command_path = False - for arg in args: - if arg in ('--help', '-h', 'help'): - break - if not arg.startswith('-'): - has_command_path = True - break - - # Only use cache for top-level help (no command arguments before --help) - if not has_command_path: - command_index = CommandIndex(self.cli_ctx) - help_index = command_index.get_help_index() - if help_index and 'root' in help_index: - logger.debug("Using cached help index for root, skipping module loading") - self._display_cached_help(help_index['root'], 'root') - sys.exit(0) - # Fast path for top-level with no args (az with no arguments) - elif use_command_index and not args: - command_index = CommandIndex(self.cli_ctx) - help_index = command_index.get_help_index() - if help_index and 'root' in help_index: - logger.debug("Using cached help index for root, skipping module loading") - self._display_cached_help(help_index['root'], 'root') - sys.exit(0) - if use_command_index: command_index = CommandIndex(self.cli_ctx) index_result = command_index.get(args) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index e6796e12be9..e99dbfb2fc0 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -518,6 +518,108 @@ def execute(self, args): command_preserve_casing = roughly_parse_command_with_casing(args) args = _pre_command_table_create(self.cli_ctx, args) + # Fast path for top-level help only (az --help or az with no args) + # Check cache before loading command table to avoid loading modules + use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) + if use_command_index: + is_help_request = args and ('--help' in args or '-h' in args or args[-1] == 'help') + is_top_level = True + + if is_help_request: + # Check if there's a command path before the help flag + for arg in args: + if arg in ('--help', '-h', 'help'): + break + if not arg.startswith('-'): + is_top_level = False + break + elif not args: + # No args means show top-level help + is_top_level = True + else: + is_top_level = False + + # Only use cache for top-level help requests + if is_top_level and (is_help_request or not args): + from azure.cli.core import CommandIndex + command_index = CommandIndex(self.cli_ctx) + help_index = command_index.get_help_index() + + if help_index and 'root' in help_index: + from azure.cli.core._help import WELCOME_MESSAGE, PRIVACY_STATEMENT + import re + + # Show privacy statement if first run + 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') + + # Show welcome message + print(WELCOME_MESSAGE) + + # Display cached help + root_help = help_index['root'] + print("\nGroup") + print(" az") + + # Import knack's formatting functions + from knack.help import _print_indent, FIRST_LINE_PREFIX, _get_hanging_indent + + groups_data = root_help.get('groups', {}) + commands_data = root_help.get('commands', {}) + + # Calculate max line length for alignment + max_line_len = 0 + for name in groups_data.keys(): + max_line_len = max(max_line_len, len(name)) + for name in commands_data.keys(): + max_line_len = max(max_line_len, len(name)) + + indent = 1 + LINE_FORMAT = '{name}{padding}{separator}{summary}' + + # Display subgroups + if groups_data: + print("\nSubgroups:") + for name in sorted(groups_data.keys()): + item = groups_data[name] + summary = item.get('summary', '') + tags = item.get('tags', '') + if tags: + summary = f"{tags} {summary}" + padding = ' ' * (max_line_len - len(name)) + line = LINE_FORMAT.format( + name=name, + padding=padding, + separator=FIRST_LINE_PREFIX if summary else '', + summary=summary + ) + _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) + + # Display commands + if commands_data: + print("\nCommands:") + for name in sorted(commands_data.keys()): + item = commands_data[name] + summary = item.get('summary', '') + tags = item.get('tags', '') + if tags: + summary = f"{tags} {summary}" + padding = ' ' * (max_line_len - len(name)) + line = LINE_FORMAT.format( + name=name, + padding=padding, + separator=FIRST_LINE_PREFIX if summary else '', + summary=summary + ) + _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) + + print() + telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) + telemetry.set_success(summary='cached-help') + return CommandResultItem(None, exit_code=0) + 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, From b9d4d1a006f2654809f171f34e3f375ac17ba52e Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 12 Feb 2026 16:16:40 +1100 Subject: [PATCH 11/38] fix: linting issues --- src/azure-cli-core/azure/cli/core/__init__.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 0d40c1e3d5e..b326075b20d 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -452,7 +452,7 @@ def _get_extension_suppressions(mod_loaders): 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) @@ -526,32 +526,32 @@ def _get_extension_suppressions(mod_loaders): if use_command_index: command_index.update(self.command_table) - + # Also cache help data for fast az --help in future # This is done after loading all modules when help data is available 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.""" from azure.cli.core._help import WELCOME_MESSAGE, PRIVACY_STATEMENT import re - + def _strip_ansi(text): """Remove ANSI color codes from text for length calculation.""" ansi_escape = re.compile(r'\x1b\[[0-9;]*m') return ansi_escape.sub('', text) - + # Show privacy statement if first run 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') - + # Show welcome message print(WELCOME_MESSAGE) - + # Display the group breadcrumb if command_path == 'root': print("\nGroup") @@ -559,19 +559,19 @@ def _strip_ansi(text): else: print("\nGroup") print(f" az {command_path}") - + # Import knack's formatting functions from knack.help import _print_indent, FIRST_LINE_PREFIX, _get_hanging_indent - + # Separate groups and commands groups_data = help_data.get('groups', {}) commands_data = help_data.get('commands', {}) - + # Helper function matching knack's _get_line_len def _get_line_len(name, tags): tags_len = len(_strip_ansi(tags)) return len(name) + tags_len + (2 if tags_len else 1) - + # Helper function matching knack's _get_padding_len def _get_padding_len(max_len, name, tags): line_len = _get_line_len(name, tags) @@ -580,7 +580,7 @@ def _get_padding_len(max_len, name, tags): else: pad_len = max_len - line_len return pad_len - + # Build items lists and calculate max_line_len across ALL items (groups + commands) # This ensures colons align across both sections max_line_len = 0 @@ -590,7 +590,7 @@ def _get_padding_len(max_len, name, tags): tags = item.get('tags', '') groups_items.append((name, tags, item.get('summary', ''))) max_line_len = max(max_line_len, _get_line_len(name, tags)) - + commands_items = [] for name in sorted(commands_data.keys()): item = commands_data[name] From 69c14b140cc60b4fd0bc0dbfd680b252927727ce Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 12 Feb 2026 16:51:46 +1100 Subject: [PATCH 12/38] fix: remove whitespace --- src/azure-cli-core/azure/cli/core/__init__.py | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index b326075b20d..031ca9a8b8b 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -597,7 +597,7 @@ def _get_padding_len(max_len, name, tags): tags = item.get('tags', '') commands_items.append((name, tags, item.get('summary', ''))) max_line_len = max(max_line_len, _get_line_len(name, tags)) - + # Display groups if groups_items: print("\nSubgroups:") @@ -613,7 +613,7 @@ def _get_padding_len(max_len, name, tags): summary=summary ) _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) - + # Display commands if commands_items: print("\nCommands:") @@ -629,23 +629,23 @@ def _get_padding_len(max_len, name, tags): summary=summary ) _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) - + print("\nTo search AI knowledge base for examples, use: az find \"az \"") - + # Show update notification from azure.cli.core.util import show_updates_available show_updates_available(new_line_after=True) - + 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 - + # Create a temporary parser to extract help information parser = AzCliCommandParser(self.cli_ctx) parser.load_command_table(self) - + # Helper to build tag string for an item def _get_tags(item): tags = [] @@ -656,40 +656,40 @@ def _get_tags(item): if hasattr(item, 'experimental_info') and item.experimental_info: tags.append(str(item.experimental_info.tag)) return ' '.join(tags) - + # Only cache root level help subparser = parser.subparsers.get(tuple()) if subparser: help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser) help_file.load(subparser) - + groups = {} commands = {} - + for child in help_file.children: if hasattr(child, 'name') and hasattr(child, 'short_summary'): # Only include top-level items (no spaces in name) child_name = child.name if ' ' in child_name: continue - + tags = _get_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 - + # Store only root level help if groups or commands: help_index_data = {'root': {'groups': groups, 'commands': commands}} command_index.INDEX[command_index._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) @@ -954,7 +954,7 @@ def get(self, args): 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 """ # Check if index is valid @@ -963,12 +963,12 @@ def get_help_index(self): if not (index_version and index_version == self.version and cloud_profile and cloud_profile == self.cloud_profile): 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 update(self, command_table): @@ -991,7 +991,7 @@ def update(self, command_table): 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 # Note: helpIndex is populated separately when az --help is displayed From 665dd7c46d0468ed135cd2103dec6085723ebfd1 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 13 Feb 2026 11:24:39 +1100 Subject: [PATCH 13/38] fix: invalidate help cache in tests --- src/azure-cli-core/azure/cli/core/__init__.py | 1 - src/azure-cli-core/azure/cli/core/tests/test_help.py | 4 ++++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 031ca9a8b8b..a9d32ac0d60 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -981,7 +981,6 @@ def update(self, command_table): self.INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = self.cloud_profile from collections import defaultdict index = defaultdict(list) - help_index = {} # Maps top-level command to short summary # self.cli_ctx.invocation.commands_loader.command_table doesn't exist in DummyCli due to the lack of invocation for command_name, command in command_table.items(): diff --git a/src/azure-cli-core/azure/cli/core/tests/test_help.py b/src/azure-cli-core/azure/cli/core/tests/test_help.py index 46204e95027..617d0944441 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_help.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_help.py @@ -163,6 +163,10 @@ def tearDown(self): # delete temporary directory to be used for temp files. shutil.rmtree(self._tempdirName) self.helps.clear() + # Invalidate help cache to prevent test data from polluting production cache + from azure.cli.core._session import INDEX + if 'helpIndex' in INDEX: + del INDEX['helpIndex'] def set_help_py(self): self.helps['test'] = """ From d4ab898daf57ce354a7a354ae550fe0375e7f775 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 13 Feb 2026 15:55:33 +1100 Subject: [PATCH 14/38] refactor: flake issues --- src/azure-cli-core/azure/cli/core/__init__.py | 1 - .../azure/cli/core/commands/__init__.py | 36 +++++++++---------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index a9d32ac0d60..0a2aea0b9fd 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -1307,4 +1307,3 @@ def get_default_cli(): logging_cls=AzCliLogging, output_cls=AzOutputProducer, help_cls=AzCliHelp) - diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index e99dbfb2fc0..15cb1d0f617 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -524,7 +524,7 @@ def execute(self, args): if use_command_index: is_help_request = args and ('--help' in args or '-h' in args or args[-1] == 'help') is_top_level = True - + if is_help_request: # Check if there's a command path before the help flag for arg in args: @@ -538,47 +538,47 @@ def execute(self, args): is_top_level = True else: is_top_level = False - + # Only use cache for top-level help requests if is_top_level and (is_help_request or not args): from azure.cli.core import CommandIndex command_index = CommandIndex(self.cli_ctx) help_index = command_index.get_help_index() - + if help_index and 'root' in help_index: from azure.cli.core._help import WELCOME_MESSAGE, PRIVACY_STATEMENT import re - + # Show privacy statement if first run 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') - + # Show welcome message print(WELCOME_MESSAGE) - + # Display cached help root_help = help_index['root'] print("\nGroup") print(" az") - + # Import knack's formatting functions from knack.help import _print_indent, FIRST_LINE_PREFIX, _get_hanging_indent - + groups_data = root_help.get('groups', {}) commands_data = root_help.get('commands', {}) - + # Calculate max line length for alignment max_line_len = 0 for name in groups_data.keys(): max_line_len = max(max_line_len, len(name)) for name in commands_data.keys(): max_line_len = max(max_line_len, len(name)) - + indent = 1 LINE_FORMAT = '{name}{padding}{separator}{summary}' - + # Display subgroups if groups_data: print("\nSubgroups:") @@ -596,7 +596,7 @@ def execute(self, args): summary=summary ) _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) - + # Display commands if commands_data: print("\nCommands:") @@ -614,7 +614,7 @@ def execute(self, args): summary=summary ) _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) - + print() telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) telemetry.set_success(summary='cached-help') @@ -680,7 +680,7 @@ def execute(self, args): self.parser.enable_autocomplete() subparser = self.parser.subparsers[tuple()] self.help.show_welcome(subparser) - + # After showing help, cache the help summaries for future fast access # This allows subsequent `az --help` calls to skip module loading use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) @@ -693,7 +693,7 @@ def execute(self, args): from azure.cli.core._help import CliGroupHelpFile help_file = CliGroupHelpFile(self.cli_ctx.help, '', subparser) help_file.load(subparser) - + # Helper to build tag string for an item def _get_tags(item): tags = [] @@ -704,11 +704,11 @@ def _get_tags(item): if hasattr(item, 'experimental_info') and item.experimental_info: tags.append(str(item.experimental_info.tag)) return ' '.join(tags) - + # Separate groups and commands groups = {} commands = {} - + for child in help_file.children: if hasattr(child, 'name') and hasattr(child, 'short_summary'): if ' ' not in child.name: # Only top-level items @@ -722,7 +722,7 @@ def _get_tags(item): groups[child.name] = item_data else: commands[child.name] = item_data - + # Store in the command index help_index_data = {'groups': groups, 'commands': commands} if groups or commands: From 40ace614c833e53ea066f100d8f6d70b53701ac8 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 13 Feb 2026 16:46:51 +1100 Subject: [PATCH 15/38] refactor: move methods to _help.py --- src/azure-cli-core/azure/cli/core/__init__.py | 126 +++--------------- src/azure-cli-core/azure/cli/core/_help.py | 102 ++++++++++++++ .../azure/cli/core/commands/__init__.py | 72 +--------- 3 files changed, 119 insertions(+), 181 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 0a2aea0b9fd..51c5f822365 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -535,106 +535,8 @@ def _get_extension_suppressions(mod_loaders): def _display_cached_help(self, help_data, command_path='root'): """Display help from cached help index without loading modules.""" - from azure.cli.core._help import WELCOME_MESSAGE, PRIVACY_STATEMENT - import re - - def _strip_ansi(text): - """Remove ANSI color codes from text for length calculation.""" - ansi_escape = re.compile(r'\x1b\[[0-9;]*m') - return ansi_escape.sub('', text) - - # Show privacy statement if first run - 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') - - # Show welcome message - print(WELCOME_MESSAGE) - - # Display the group breadcrumb - if command_path == 'root': - print("\nGroup") - print(" az") - else: - print("\nGroup") - print(f" az {command_path}") - - # Import knack's formatting functions - from knack.help import _print_indent, FIRST_LINE_PREFIX, _get_hanging_indent - - # Separate groups and commands - groups_data = help_data.get('groups', {}) - commands_data = help_data.get('commands', {}) - - # Helper function matching knack's _get_line_len - def _get_line_len(name, tags): - tags_len = len(_strip_ansi(tags)) - return len(name) + tags_len + (2 if tags_len else 1) - - # Helper function matching knack's _get_padding_len - def _get_padding_len(max_len, name, tags): - line_len = _get_line_len(name, tags) - if tags: - pad_len = max_len - line_len + 1 - else: - pad_len = max_len - line_len - return pad_len - - # Build items lists and calculate max_line_len across ALL items (groups + commands) - # This ensures colons align across both sections - max_line_len = 0 - groups_items = [] - for name in sorted(groups_data.keys()): - item = groups_data[name] - tags = item.get('tags', '') - groups_items.append((name, tags, item.get('summary', ''))) - max_line_len = max(max_line_len, _get_line_len(name, tags)) - - commands_items = [] - for name in sorted(commands_data.keys()): - item = commands_data[name] - tags = item.get('tags', '') - commands_items.append((name, tags, item.get('summary', ''))) - max_line_len = max(max_line_len, _get_line_len(name, tags)) - - # Display groups - if groups_items: - print("\nSubgroups:") - indent = 1 - LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' - for name, tags, summary in groups_items: - padding = ' ' * _get_padding_len(max_line_len, name, tags) - 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)) - - # Display commands - if commands_items: - print("\nCommands:") - indent = 1 - LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' - for name, tags, summary in commands_items: - padding = ' ' * _get_padding_len(max_line_len, name, tags) - 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)) - - print("\nTo search AI knowledge base for examples, use: az find \"az \"") - - # Show update notification - from azure.cli.core.util import show_updates_available - show_updates_available(new_line_after=True) + # 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.""" @@ -877,6 +779,16 @@ def __init__(self, cli_ctx=None): 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. @@ -902,10 +814,7 @@ def get(self, args): """ # 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 @@ -957,11 +866,7 @@ def get_help_index(self): :return: Dictionary mapping top-level commands to their short summaries, or None if not available """ - # Check if index is valid - 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(): return None help_index = self.INDEX.get(self._HELP_INDEX, {}) @@ -993,8 +898,7 @@ def update(self, command_table): elapsed_time = timeit.default_timer() - start_time self.INDEX[self._COMMAND_INDEX] = index - # Note: helpIndex is populated separately when az --help is displayed - # We don't populate it here because the help data isn't available yet + # Note: helpIndex is populated by _cache_help_index() when all modules are loaded logger.debug("Updated command index in %.3f seconds.", elapsed_time) def invalidate(self): diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 9f192da76c9..4fad52027bb 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -241,6 +241,108 @@ def update_loaders_with_help_file_contents(self, nouns): def update_examples(help_file): pass + def show_cached_help(self, help_data, command_path='root'): + """Display help from cached help index without loading modules.""" + import re + + def _strip_ansi(text): + """Remove ANSI color codes from text for length calculation.""" + ansi_escape = re.compile(r'\x1b\[[0-9;]*m') + return ansi_escape.sub('', text) + + # Show privacy statement if first run + 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') + + # Show welcome message + print(WELCOME_MESSAGE) + + # Display the group breadcrumb + if command_path == 'root': + print("\nGroup") + print(" az") + else: + print("\nGroup") + print(f" az {command_path}") + + # Import knack's formatting functions + from knack.help import _print_indent, FIRST_LINE_PREFIX, _get_hanging_indent + + # Separate groups and commands + groups_data = help_data.get('groups', {}) + commands_data = help_data.get('commands', {}) + + # Helper function matching knack's _get_line_len + def _get_line_len(name, tags): + tags_len = len(_strip_ansi(tags)) + return len(name) + tags_len + (2 if tags_len else 1) + + # Helper function matching knack's _get_padding_len + def _get_padding_len(max_len, name, tags): + line_len = _get_line_len(name, tags) + if tags: + pad_len = max_len - line_len + 1 + else: + pad_len = max_len - line_len + return pad_len + + # Build items lists and calculate max_line_len across ALL items (groups + commands) + # This ensures colons align across both sections + max_line_len = 0 + groups_items = [] + for name in sorted(groups_data.keys()): + item = groups_data[name] + tags = item.get('tags', '') + groups_items.append((name, tags, item.get('summary', ''))) + max_line_len = max(max_line_len, _get_line_len(name, tags)) + + commands_items = [] + for name in sorted(commands_data.keys()): + item = commands_data[name] + tags = item.get('tags', '') + commands_items.append((name, tags, item.get('summary', ''))) + max_line_len = max(max_line_len, _get_line_len(name, tags)) + + # Display groups + if groups_items: + print("\nSubgroups:") + indent = 1 + LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' + for name, tags, summary in groups_items: + padding = ' ' * _get_padding_len(max_line_len, name, tags) + 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)) + + # Display commands + if commands_items: + print("\nCommands:") + indent = 1 + LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' + for name, tags, summary in commands_items: + padding = ' ' * _get_padding_len(max_line_len, name, tags) + 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)) + + print("\nTo search AI knowledge base for examples, use: az find \"az \"") + + # Show update notification + from azure.cli.core.util import show_updates_available + show_updates_available(new_line_after=True) + class CliHelpFile(KnackHelpFile): diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 15cb1d0f617..6344038f647 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -546,76 +546,8 @@ def execute(self, args): help_index = command_index.get_help_index() if help_index and 'root' in help_index: - from azure.cli.core._help import WELCOME_MESSAGE, PRIVACY_STATEMENT - import re - - # Show privacy statement if first run - 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') - - # Show welcome message - print(WELCOME_MESSAGE) - - # Display cached help - root_help = help_index['root'] - print("\nGroup") - print(" az") - - # Import knack's formatting functions - from knack.help import _print_indent, FIRST_LINE_PREFIX, _get_hanging_indent - - groups_data = root_help.get('groups', {}) - commands_data = root_help.get('commands', {}) - - # Calculate max line length for alignment - max_line_len = 0 - for name in groups_data.keys(): - max_line_len = max(max_line_len, len(name)) - for name in commands_data.keys(): - max_line_len = max(max_line_len, len(name)) - - indent = 1 - LINE_FORMAT = '{name}{padding}{separator}{summary}' - - # Display subgroups - if groups_data: - print("\nSubgroups:") - for name in sorted(groups_data.keys()): - item = groups_data[name] - summary = item.get('summary', '') - tags = item.get('tags', '') - if tags: - summary = f"{tags} {summary}" - padding = ' ' * (max_line_len - len(name)) - line = LINE_FORMAT.format( - name=name, - padding=padding, - separator=FIRST_LINE_PREFIX if summary else '', - summary=summary - ) - _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) - - # Display commands - if commands_data: - print("\nCommands:") - for name in sorted(commands_data.keys()): - item = commands_data[name] - summary = item.get('summary', '') - tags = item.get('tags', '') - if tags: - summary = f"{tags} {summary}" - padding = ' ' * (max_line_len - len(name)) - line = LINE_FORMAT.format( - name=name, - padding=padding, - separator=FIRST_LINE_PREFIX if summary else '', - summary=summary - ) - _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) - - print() + # Display cached help using the help system + self.help.show_cached_help(help_index['root'], 'root') telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) telemetry.set_success(summary='cached-help') return CommandResultItem(None, exit_code=0) From 47c88ec6dec7056f8557b718d08cfb25e26365b4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 13 Feb 2026 17:11:33 +1100 Subject: [PATCH 16/38] refactor: top-level help method --- .../azure/cli/core/commands/__init__.py | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 6344038f647..fa2f5b94017 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -521,36 +521,17 @@ def execute(self, args): # Fast path for top-level help only (az --help or az with no args) # Check cache before loading command table to avoid loading modules use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) - if use_command_index: - is_help_request = args and ('--help' in args or '-h' in args or args[-1] == 'help') - is_top_level = True - - if is_help_request: - # Check if there's a command path before the help flag - for arg in args: - if arg in ('--help', '-h', 'help'): - break - if not arg.startswith('-'): - is_top_level = False - break - elif not args: - # No args means show top-level help - is_top_level = True - else: - is_top_level = False - - # Only use cache for top-level help requests - if is_top_level and (is_help_request or not args): - from azure.cli.core import CommandIndex - command_index = CommandIndex(self.cli_ctx) - help_index = command_index.get_help_index() - - if help_index and 'root' in help_index: - # Display cached help using the help system - self.help.show_cached_help(help_index['root'], 'root') - telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) - telemetry.set_success(summary='cached-help') - return CommandResultItem(None, exit_code=0) + if use_command_index and self._is_top_level_help_request(args): + from azure.cli.core import CommandIndex + command_index = CommandIndex(self.cli_ctx) + help_index = command_index.get_help_index() + + if help_index and 'root' in help_index: + # Display cached help using the help system + self.help.show_cached_help(help_index['root'], 'root') + telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) + telemetry.set_success(summary='cached-help') + return CommandResultItem(None, exit_code=0) self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) self.commands_loader.load_command_table(args) @@ -786,6 +767,35 @@ def _extract_parameter_names(args): 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: + - No arguments (just 'az') + - Help request with no command path (az --help, az -h, az help) + + Returns False for: + - Commands with help (az network --help) + - Any non-help request + """ + if not args: + return True + + is_help_request = '--help' in args or '-h' in args or args[-1] == 'help' + if not is_help_request: + return False + + # Check if there's a command path before the help flag + for arg in args: + if arg in ('--help', '-h', 'help'): + break + if not arg.startswith('-'): + # Found a command name before help flag + return False + + return True + def _run_job(self, expanded_arg, cmd_copy): params = self._filter_params(expanded_arg) try: From b0f27b93495d61e9bcb75b74eca724e04c7fd396 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 09:58:32 +1100 Subject: [PATCH 17/38] fix: remove redundant comments and whitespace issue --- .../azure/cli/core/commands/__init__.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index fa2f5b94017..cd78eef41ab 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -769,16 +769,7 @@ def _extract_parameter_names(args): @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: - - No arguments (just 'az') - - Help request with no command path (az --help, az -h, az help) - - Returns False for: - - Commands with help (az network --help) - - Any non-help request - """ + """Determine if this is a top-level help request (az --help or just az).""" if not args: return True From bbd9b9259a6f6b2a6f6531553ea4f5aa661c8cb6 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 11:54:43 +1100 Subject: [PATCH 18/38] refactor: remove redundant import --- src/azure-cli-core/azure/cli/core/_help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 4fad52027bb..9755fc01d1f 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -268,7 +268,7 @@ def _strip_ansi(text): print(f" az {command_path}") # Import knack's formatting functions - from knack.help import _print_indent, FIRST_LINE_PREFIX, _get_hanging_indent + from knack.help import FIRST_LINE_PREFIX, _get_hanging_indent # Separate groups and commands groups_data = help_data.get('groups', {}) From 88611d86814b34f20c6267adadae5d4191d76e7e Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 12:20:17 +1100 Subject: [PATCH 19/38] refactor: add help index setter --- src/azure-cli-core/azure/cli/core/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 51c5f822365..b8ed3ee0a82 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -589,7 +589,7 @@ def _get_tags(item): # Store only root level help if groups or commands: help_index_data = {'root': {'groups': groups, 'commands': commands}} - command_index.INDEX[command_index._HELP_INDEX] = help_index_data + 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 @@ -876,6 +876,13 @@ def get_help_index(self): 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. From 73064fe8741bc3af91f25ed5b76dc7a4485711c7 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 13:28:03 +1100 Subject: [PATCH 20/38] refactor: refactoring methods for display cached help --- src/azure-cli-core/azure/cli/core/__init__.py | 9 -- src/azure-cli-core/azure/cli/core/_help.py | 124 +++++++----------- .../azure/cli/core/commands/__init__.py | 2 - 3 files changed, 49 insertions(+), 86 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index b8ed3ee0a82..e94db29e0bc 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -526,9 +526,6 @@ def _get_extension_suppressions(mod_loaders): if use_command_index: command_index.update(self.command_table) - - # Also cache help data for fast az --help in future - # This is done after loading all modules when help data is available self._cache_help_index(command_index) return self.command_table @@ -544,11 +541,9 @@ def _cache_help_index(self, command_index): from azure.cli.core.parser import AzCliCommandParser from azure.cli.core._help import CliGroupHelpFile - # Create a temporary parser to extract help information parser = AzCliCommandParser(self.cli_ctx) parser.load_command_table(self) - # Helper to build tag string for an item def _get_tags(item): tags = [] if hasattr(item, 'deprecate_info') and item.deprecate_info: @@ -559,7 +554,6 @@ def _get_tags(item): tags.append(str(item.experimental_info.tag)) return ' '.join(tags) - # Only cache root level help subparser = parser.subparsers.get(tuple()) if subparser: help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser) @@ -570,7 +564,6 @@ def _get_tags(item): for child in help_file.children: if hasattr(child, 'name') and hasattr(child, 'short_summary'): - # Only include top-level items (no spaces in name) child_name = child.name if ' ' in child_name: continue @@ -586,7 +579,6 @@ def _get_tags(item): else: commands[child_name] = item_data - # Store only root level help if groups or commands: help_index_data = {'root': {'groups': groups, 'commands': commands}} command_index.set_help_index(help_index_data) @@ -905,7 +897,6 @@ def update(self, command_table): elapsed_time = timeit.default_timer() - start_time self.INDEX[self._COMMAND_INDEX] = index - # Note: helpIndex is populated by _cache_help_index() when all modules are loaded logger.debug("Updated command index in %.3f seconds.", elapsed_time) def invalidate(self): diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 9755fc01d1f..602787c4846 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -241,25 +241,57 @@ def update_loaders_with_help_file_contents(self, nouns): def update_examples(help_file): pass - def show_cached_help(self, help_data, command_path='root'): - """Display help from cached help index without loading modules.""" + @staticmethod + def _strip_ansi(text): + """Remove ANSI color codes from text for length calculation.""" import re + ansi_escape = re.compile(r'\x1b\[[0-9;]*m') + return ansi_escape.sub('', text) + + @staticmethod + def _build_cached_help_items(data): + """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] + tags = item.get('tags', '') + tags_len = len(AzCliHelp._strip_ansi(tags)) + line_len = _get_line_len(name, tags_len) + items.append((name, tags, line_len, item.get('summary', ''))) + return items - def _strip_ansi(text): - """Remove ANSI color codes from text for length calculation.""" - ansi_escape = re.compile(r'\x1b\[[0-9;]*m') - return ansi_escape.sub('', text) + @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, command_path='root'): + """Display help from cached help index without loading modules.""" - # Show privacy statement if first run 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') - # Show welcome message print(WELCOME_MESSAGE) - # Display the group breadcrumb if command_path == 'root': print("\nGroup") print(" az") @@ -267,79 +299,21 @@ def _strip_ansi(text): print("\nGroup") print(f" az {command_path}") - # Import knack's formatting functions - from knack.help import FIRST_LINE_PREFIX, _get_hanging_indent - - # Separate groups and commands groups_data = help_data.get('groups', {}) commands_data = help_data.get('commands', {}) - # Helper function matching knack's _get_line_len - def _get_line_len(name, tags): - tags_len = len(_strip_ansi(tags)) - return len(name) + tags_len + (2 if tags_len else 1) - - # Helper function matching knack's _get_padding_len - def _get_padding_len(max_len, name, tags): - line_len = _get_line_len(name, tags) - if tags: - pad_len = max_len - line_len + 1 - else: - pad_len = max_len - line_len - return pad_len - - # Build items lists and calculate max_line_len across ALL items (groups + commands) - # This ensures colons align across both sections - max_line_len = 0 - groups_items = [] - for name in sorted(groups_data.keys()): - item = groups_data[name] - tags = item.get('tags', '') - groups_items.append((name, tags, item.get('summary', ''))) - max_line_len = max(max_line_len, _get_line_len(name, tags)) + groups_items = self._build_cached_help_items(groups_data) + commands_items = self._build_cached_help_items(commands_data) + max_line_len = max( + (line_len for _, _, line_len, _ in groups_items + commands_items), + default=0 + ) - commands_items = [] - for name in sorted(commands_data.keys()): - item = commands_data[name] - tags = item.get('tags', '') - commands_items.append((name, tags, item.get('summary', ''))) - max_line_len = max(max_line_len, _get_line_len(name, tags)) - - # Display groups - if groups_items: - print("\nSubgroups:") - indent = 1 - LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' - for name, tags, summary in groups_items: - padding = ' ' * _get_padding_len(max_line_len, name, tags) - 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)) - - # Display commands - if commands_items: - print("\nCommands:") - indent = 1 - LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' - for name, tags, summary in commands_items: - padding = ' ' * _get_padding_len(max_line_len, name, tags) - 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)) + self._print_cached_help_section(groups_items, "Subgroups:", max_line_len) + self._print_cached_help_section(commands_items, "Commands:", max_line_len) print("\nTo search AI knowledge base for examples, use: az find \"az \"") - # Show update notification from azure.cli.core.util import show_updates_available show_updates_available(new_line_after=True) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index cd78eef41ab..0f87307be4e 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -777,12 +777,10 @@ def _is_top_level_help_request(args): if not is_help_request: return False - # Check if there's a command path before the help flag for arg in args: if arg in ('--help', '-h', 'help'): break if not arg.startswith('-'): - # Found a command name before help flag return False return True From 1ece2ec832af8fb3d942377b5e57c82a6e4693ac Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 13:34:44 +1100 Subject: [PATCH 21/38] refactor: refactor print_Az_msg to use same as non-cached path --- src/azure-cli-core/azure/cli/core/_help.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 602787c4846..10de2553287 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -312,7 +312,8 @@ def show_cached_help(self, help_data, command_path='root'): self._print_cached_help_section(groups_items, "Subgroups:", max_line_len) self._print_cached_help_section(commands_items, "Commands:", max_line_len) - print("\nTo search AI knowledge base for examples, use: az find \"az \"") + command = '' if command_path == 'root' else command_path + self._print_az_find_message(command) from azure.cli.core.util import show_updates_available show_updates_available(new_line_after=True) From d744377a8ea6b7067c91e717da3aa31f9a58f73a Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 14:07:30 +1100 Subject: [PATCH 22/38] refactor: add helper method to check command index --- .../azure/cli/core/commands/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 0f87307be4e..487e4e21939 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -519,9 +519,7 @@ def execute(self, args): args = _pre_command_table_create(self.cli_ctx, args) # Fast path for top-level help only (az --help or az with no args) - # Check cache before loading command table to avoid loading modules - use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) - if use_command_index and self._is_top_level_help_request(args): + if self._should_use_command_index() and self._is_top_level_help_request(args): from azure.cli.core import CommandIndex command_index = CommandIndex(self.cli_ctx) help_index = command_index.get_help_index() @@ -595,8 +593,7 @@ def execute(self, args): self.help.show_welcome(subparser) # After showing help, cache the help summaries for future fast access - # This allows subsequent `az --help` calls to skip module loading - use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) + 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: @@ -785,6 +782,10 @@ def _is_top_level_help_request(args): return True + 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 _run_job(self, expanded_arg, cmd_copy): params = self._filter_params(expanded_arg) try: From 250fa8e6d4afc1ac89a0965539b1e72c5a394fa5 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 14:12:25 +1100 Subject: [PATCH 23/38] refactor: top-level help method return from parent execute method --- .../azure/cli/core/commands/__init__.py | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 487e4e21939..7b589487755 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -518,18 +518,10 @@ def execute(self, args): command_preserve_casing = roughly_parse_command_with_casing(args) args = _pre_command_table_create(self.cli_ctx, args) - # Fast path for top-level help only (az --help or az with no args) if self._should_use_command_index() and self._is_top_level_help_request(args): - from azure.cli.core import CommandIndex - command_index = CommandIndex(self.cli_ctx) - help_index = command_index.get_help_index() - - if help_index and 'root' in help_index: - # Display cached help using the help system - self.help.show_cached_help(help_index['root'], 'root') - telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) - telemetry.set_success(summary='cached-help') - return CommandResultItem(None, exit_code=0) + result = self._try_show_cached_help(command_preserve_casing) + if result: + return result self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) self.commands_loader.load_command_table(args) @@ -786,6 +778,24 @@ 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 _try_show_cached_help(self, command_preserve_casing): + """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 and 'root' in help_index: + # Display cached help using the help system + self.help.show_cached_help(help_index['root'], 'root') + telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) + telemetry.set_success(summary='cached-help') + return CommandResultItem(None, exit_code=0) + + return None + def _run_job(self, expanded_arg, cmd_copy): params = self._filter_params(expanded_arg) try: From f8053478744cf26a0487b1363e3f0c052e2e92b8 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 14:28:56 +1100 Subject: [PATCH 24/38] refactor: break out saving help cache from execute method --- src/azure-cli-core/azure/cli/core/_help.py | 2 + .../azure/cli/core/commands/__init__.py | 89 ++++++++++--------- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 10de2553287..4d506b73924 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -312,6 +312,8 @@ def show_cached_help(self, help_data, command_path='root'): 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 command = '' if command_path == 'root' else command_path self._print_az_find_message(command) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 7b589487755..cde01b8a9c7 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -584,54 +584,11 @@ def execute(self, args): subparser = self.parser.subparsers[tuple()] self.help.show_welcome(subparser) - # After showing help, cache the help summaries for future fast access 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: - from azure.cli.core import CommandIndex - command_index = CommandIndex(self.cli_ctx) - # Extract help data from the parser that was just used - from azure.cli.core._help import CliGroupHelpFile - help_file = CliGroupHelpFile(self.cli_ctx.help, '', subparser) - help_file.load(subparser) - - # Helper to build tag string for an item - def _get_tags(item): - tags = [] - if hasattr(item, 'deprecate_info') and item.deprecate_info: - tags.append(str(item.deprecate_info.tag)) - if hasattr(item, 'preview_info') and item.preview_info: - tags.append(str(item.preview_info.tag)) - if hasattr(item, 'experimental_info') and item.experimental_info: - tags.append(str(item.experimental_info.tag)) - return ' '.join(tags) - - # Separate groups and commands - groups = {} - commands = {} - - for child in help_file.children: - if hasattr(child, 'name') and hasattr(child, 'short_summary'): - if ' ' not in child.name: # Only top-level items - tags = _get_tags(child) - item_data = { - 'summary': child.short_summary, - 'tags': tags - } - # Check if it's a group or command - if child.type == 'group': - groups[child.name] = item_data - else: - commands[child.name] = item_data - - # Store in the command index - help_index_data = {'groups': groups, 'commands': commands} - if groups or commands: - from azure.cli.core._session import INDEX - from azure.cli.core import __version__ - INDEX['helpIndex'] = help_index_data - logger.debug("Cached %d groups and %d commands for fast access", len(groups), len(commands)) + self._save_help_to_command_index(subparser) except Exception as ex: # pylint: disable=broad-except logger.debug("Failed to cache help data: %s", ex) @@ -796,6 +753,50 @@ def _try_show_cached_help(self, command_preserve_casing): 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 + + command_index = CommandIndex(self.cli_ctx) + help_file = CliGroupHelpFile(self.cli_ctx.help, '', subparser) + help_file.load(subparser) + + # Helper to build tag string for an item + def _get_tags(item): + tags = [] + if hasattr(item, 'deprecate_info') and item.deprecate_info: + tags.append(str(item.deprecate_info.tag)) + if hasattr(item, 'preview_info') and item.preview_info: + tags.append(str(item.preview_info.tag)) + if hasattr(item, 'experimental_info') and item.experimental_info: + tags.append(str(item.experimental_info.tag)) + return ' '.join(tags) + + # Separate groups and commands + groups = {} + commands = {} + + for child in help_file.children: + if hasattr(child, 'name') and hasattr(child, 'short_summary'): + if ' ' not in child.name: # Only top-level items + tags = _get_tags(child) + item_data = { + 'summary': child.short_summary, + 'tags': tags + } + # Check if it's a group or command + if child.type == 'group': + groups[child.name] = item_data + else: + commands[child.name] = item_data + + # 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: From a95a2724bac058d50663e4946ee892b1fab3bc5b Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 15:54:19 +1100 Subject: [PATCH 25/38] Refactor: remove trailing whitespace --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index cde01b8a9c7..d701d950cd6 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -750,14 +750,14 @@ def _try_show_cached_help(self, command_preserve_casing): telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) telemetry.set_success(summary='cached-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 - + command_index = CommandIndex(self.cli_ctx) help_file = CliGroupHelpFile(self.cli_ctx.help, '', subparser) help_file.load(subparser) From 35abb63eb25a572ba45bf615ae416a6c4045a920 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 16:18:55 +1100 Subject: [PATCH 26/38] refactor: change k/v index to match in all paths --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index d701d950cd6..95ba7246620 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -759,7 +759,7 @@ def _save_help_to_command_index(self, subparser): from azure.cli.core._help import CliGroupHelpFile command_index = CommandIndex(self.cli_ctx) - help_file = CliGroupHelpFile(self.cli_ctx.help, '', subparser) + help_file = CliGroupHelpFile(self.help, '', subparser) help_file.load(subparser) # Helper to build tag string for an item @@ -792,7 +792,7 @@ def _get_tags(item): commands[child.name] = item_data # Store in the command index - help_index_data = {'groups': groups, 'commands': commands} + help_index_data = {'root': {'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)) From 1924c7f1281c1625eb91fcdbf3ff68cc79b00e83 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 16:24:45 +1100 Subject: [PATCH 27/38] refactor: simplify logic for checking for top-level cmdn --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 95ba7246620..c48df432af9 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -719,17 +719,13 @@ def _is_top_level_help_request(args): if not args: return True - is_help_request = '--help' in args or '-h' in args or args[-1] == 'help' - if not is_help_request: - return False - for arg in args: if arg in ('--help', '-h', 'help'): - break + return True if not arg.startswith('-'): return False - return True + return False def _should_use_command_index(self): """Check if command index optimization is enabled.""" From 253aaed8e57df3f0e4de6634ab1130cf72e51224 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 16:55:34 +1100 Subject: [PATCH 28/38] refactor: simplify colorizing tags --- src/azure-cli-core/azure/cli/core/__init__.py | 14 +--- src/azure-cli-core/azure/cli/core/_help.py | 67 ++++++++++++++++--- .../azure/cli/core/commands/__init__.py | 15 +---- 3 files changed, 60 insertions(+), 36 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index e94db29e0bc..c8a7c0de67c 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -539,21 +539,11 @@ 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 + from azure.cli.core._help import CliGroupHelpFile, get_help_item_tags parser = AzCliCommandParser(self.cli_ctx) parser.load_command_table(self) - def _get_tags(item): - tags = [] - if hasattr(item, 'deprecate_info') and item.deprecate_info: - tags.append(str(item.deprecate_info.tag)) - if hasattr(item, 'preview_info') and item.preview_info: - tags.append(str(item.preview_info.tag)) - if hasattr(item, 'experimental_info') and item.experimental_info: - tags.append(str(item.experimental_info.tag)) - return ' '.join(tags) - subparser = parser.subparsers.get(tuple()) if subparser: help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser) @@ -568,7 +558,7 @@ def _get_tags(item): if ' ' in child_name: continue - tags = _get_tags(child) + tags = get_help_item_tags(child) item_data = { 'summary': child.short_summary, 'tags': tags diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 4d506b73924..4a9bcb31c62 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -46,6 +46,29 @@ """ +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: + # Get plain text tag by accessing the underlying message + tag_obj = item.deprecate_info.tag + # ColorizedString's _message has the plain text, str() would include ANSI codes + tag_text = tag_obj._message if hasattr(tag_obj, '_message') else str(tag_obj) + tags.append(tag_text) + if hasattr(item, 'preview_info') and item.preview_info: + tag_obj = item.preview_info.tag + tag_text = tag_obj._message if hasattr(tag_obj, '_message') else str(tag_obj) + tags.append(tag_text) + if hasattr(item, 'experimental_info') and item.experimental_info: + tag_obj = item.experimental_info.tag + tag_text = tag_obj._message if hasattr(tag_obj, '_message') else str(tag_obj) + tags.append(tag_text) + return ' '.join(tags) + + # PrintMixin class to decouple printing functionality from AZCLIHelp class. # Most of these methods override print methods in CLIHelp class CLIPrintMixin(CLIHelp): @@ -242,23 +265,45 @@ def update_examples(help_file): pass @staticmethod - def _strip_ansi(text): - """Remove ANSI color codes from text for length calculation.""" - import re - ansi_escape = re.compile(r'\x1b\[[0-9;]*m') - return ansi_escape.sub('', text) + 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): + 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] - tags = item.get('tags', '') - tags_len = len(AzCliHelp._strip_ansi(tags)) + 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, tags, line_len, item.get('summary', ''))) + items.append((name, colored_tags, line_len, item.get('summary', ''))) return items @staticmethod @@ -302,8 +347,8 @@ def show_cached_help(self, help_data, command_path='root'): groups_data = help_data.get('groups', {}) commands_data = help_data.get('commands', {}) - groups_items = self._build_cached_help_items(groups_data) - commands_items = self._build_cached_help_items(commands_data) + 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 diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index c48df432af9..20922b838ed 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -752,23 +752,12 @@ def _try_show_cached_help(self, command_preserve_casing): 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 + from azure.cli.core._help import CliGroupHelpFile, get_help_item_tags command_index = CommandIndex(self.cli_ctx) help_file = CliGroupHelpFile(self.help, '', subparser) help_file.load(subparser) - # Helper to build tag string for an item - def _get_tags(item): - tags = [] - if hasattr(item, 'deprecate_info') and item.deprecate_info: - tags.append(str(item.deprecate_info.tag)) - if hasattr(item, 'preview_info') and item.preview_info: - tags.append(str(item.preview_info.tag)) - if hasattr(item, 'experimental_info') and item.experimental_info: - tags.append(str(item.experimental_info.tag)) - return ' '.join(tags) - # Separate groups and commands groups = {} commands = {} @@ -776,7 +765,7 @@ def _get_tags(item): for child in help_file.children: if hasattr(child, 'name') and hasattr(child, 'short_summary'): if ' ' not in child.name: # Only top-level items - tags = _get_tags(child) + tags = get_help_item_tags(child) item_data = { 'summary': child.short_summary, 'tags': tags From 9d8580c74d2728eb72695ea70a915f9df316950f Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Feb 2026 17:00:27 +1100 Subject: [PATCH 29/38] refactor: extract formating method --- src/azure-cli-core/azure/cli/core/__init__.py | 22 ++----------- src/azure-cli-core/azure/cli/core/_help.py | 33 +++++++++++++++++++ .../azure/cli/core/commands/__init__.py | 20 ++--------- 3 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index c8a7c0de67c..35b557d72f2 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -539,7 +539,7 @@ 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, get_help_item_tags + from azure.cli.core._help import CliGroupHelpFile, extract_help_index_data parser = AzCliCommandParser(self.cli_ctx) parser.load_command_table(self) @@ -549,25 +549,7 @@ def _cache_help_index(self, command_index): help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser) help_file.load(subparser) - groups = {} - commands = {} - - for child in help_file.children: - if hasattr(child, 'name') and hasattr(child, 'short_summary'): - child_name = child.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 + groups, commands = extract_help_index_data(help_file) if groups or commands: help_index_data = {'root': {'groups': groups, 'commands': commands}} diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 4a9bcb31c62..091c93b0d0a 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -69,6 +69,39 @@ def get_help_item_tags(item): 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): diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 20922b838ed..f9c0da3597e 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -752,29 +752,13 @@ def _try_show_cached_help(self, command_preserve_casing): 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, get_help_item_tags + 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) - # Separate groups and commands - groups = {} - commands = {} - - for child in help_file.children: - if hasattr(child, 'name') and hasattr(child, 'short_summary'): - if ' ' not in child.name: # Only top-level items - tags = get_help_item_tags(child) - item_data = { - 'summary': child.short_summary, - 'tags': tags - } - # Check if it's a group or command - if child.type == 'group': - groups[child.name] = item_data - else: - commands[child.name] = item_data + groups, commands = extract_help_index_data(help_file) # Store in the command index help_index_data = {'root': {'groups': groups, 'commands': commands}} From 3801740b1ce1a8dcc6cae67bb8789ddbd95ee4fa Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Feb 2026 09:16:49 +1100 Subject: [PATCH 30/38] refactor: remove whitespace --- src/azure-cli-core/azure/cli/core/_help.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 091c93b0d0a..b9129276286 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -71,10 +71,10 @@ def get_help_item_tags(item): 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) """ From cceacd96a1c4b98de7996a40b6a99c17a58a9535 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Feb 2026 10:03:13 +1100 Subject: [PATCH 31/38] test: add tests for cache help CRUD methods --- .../azure/cli/core/tests/test_help.py | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_help.py b/src/azure-cli-core/azure/cli/core/tests/test_help.py index 617d0944441..38ca13307ff 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_help.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_help.py @@ -502,6 +502,125 @@ def test_load_from_help_json(self, mocked_load, mocked_pkg_util, mocked_getmembe self.assertEqual(obj_param_dict["--arg2 -b"].value_sources[2]['link'], {"command": "az test show", "title": "Show test details"}) + def test_help_cache_extraction(self): + """Test that help data is correctly extracted for caching.""" + from azure.cli.core._help import extract_help_index_data + from unittest.mock import Mock + + mock_help_file = Mock() + mock_child_group = Mock() + mock_child_group.name = 'compute' + mock_child_group.type = 'group' + mock_child_group.short_summary = 'Manage compute resources' + mock_child_group.group_name = 'compute' + mock_child_group._is_command = Mock(return_value=False) + # Mock the tag info attributes to return None (no tags) + mock_child_group.deprecate_info = None + mock_child_group.preview_info = None + mock_child_group.experimental_info = None + + mock_child_command = Mock() + mock_child_command.name = 'login' + mock_child_command.type = 'command' + mock_child_command.short_summary = 'Log in to Azure' + mock_child_command.group_name = None + mock_child_command._is_command = Mock(return_value=True) + mock_child_command.deprecate_info = None + mock_child_command.preview_info = None + mock_child_command.experimental_info = None + + mock_help_file.children = [mock_child_group, mock_child_command] + + groups, commands = extract_help_index_data(mock_help_file) + + self.assertIsInstance(groups, dict) + self.assertIsInstance(commands, dict) + self.assertIn('compute', groups) + self.assertIn('login', commands) + self.assertEqual(groups['compute']['summary'], 'Manage compute resources') + self.assertEqual(commands['login']['summary'], 'Log in to Azure') + + def test_help_cache_storage_and_retrieval(self): + """Test that help cache is stored and can be retrieved.""" + from azure.cli.core import CommandIndex + + test_help_data = { + 'root': { + 'groups': { + 'test-group': {'summary': 'Test group summary', 'tags': '[Preview]'} + }, + 'commands': { + 'test-cmd': {'summary': 'Test command summary', 'tags': ''} + } + } + } + + command_index = CommandIndex(self.test_cli) + command_index.set_help_index(test_help_data) + + retrieved = command_index.get_help_index() + + self.assertIsNotNone(retrieved) + self.assertIn('root', retrieved) + self.assertIn('groups', retrieved['root']) + self.assertIn('commands', retrieved['root']) + self.assertEqual(retrieved['root']['groups']['test-group']['summary'], 'Test group summary') + self.assertEqual(retrieved['root']['commands']['test-cmd']['summary'], 'Test command summary') + + def test_help_cache_invalidation(self): + """Test that cache is invalidated correctly.""" + from azure.cli.core import CommandIndex + from azure.cli.core._session import INDEX + + test_help_data = {'root': {'groups': {}, 'commands': {}}} + command_index = CommandIndex(self.test_cli) + command_index.set_help_index(test_help_data) + + self.assertIn('helpIndex', INDEX) + + command_index.invalidate() + + self.assertEqual(INDEX.get('helpIndex'), {}) + + def test_show_cached_help_output(self): + """Test that cached help is displayed correctly.""" + from azure.cli.core._help import AzCliHelp + from azure.cli.core.mock import DummyCli + from io import StringIO + import sys + + test_help_data = { + 'groups': { + 'network': {'summary': 'Manage Azure Network resources.', 'tags': ''}, + 'vm': {'summary': 'Manage Linux or Windows virtual machines.', 'tags': '[Preview]'} + }, + 'commands': { + 'login': {'summary': 'Log in to Azure.', 'tags': ''}, + 'version': {'summary': 'Show the versions of Azure CLI modules.', 'tags': ''} + } + } + + cli = DummyCli() + help_obj = AzCliHelp(cli) + + captured_output = StringIO() + sys.stdout = captured_output + + try: + help_obj.show_cached_help(test_help_data, command_path='root') + output = captured_output.getvalue() + + self.assertIn('Subgroups:', output) + self.assertIn('Commands:', output) + self.assertIn('network', output) + self.assertIn('Manage Azure Network resources', output) + self.assertIn('vm', output) + self.assertIn('login', output) + self.assertIn('version', output) + self.assertIn('az find', output) + finally: + sys.stdout = sys.__stdout__ + # create a temporary file in the temp dir. Return the path of the file. def _create_new_temp_file(self, data, suffix=""): with tempfile.NamedTemporaryFile(mode='w', dir=self._tempdirName, delete=False, suffix=suffix) as f: From 14efeb024cb961b189f4ce1c29a64aac4566cee5 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Feb 2026 10:19:54 +1100 Subject: [PATCH 32/38] test: change to directly access INDEX as cache validation rules fail in pipelines --- src/azure-cli-core/azure/cli/core/tests/test_help.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_help.py b/src/azure-cli-core/azure/cli/core/tests/test_help.py index 38ca13307ff..e9f623a75fd 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_help.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_help.py @@ -543,6 +543,7 @@ def test_help_cache_extraction(self): def test_help_cache_storage_and_retrieval(self): """Test that help cache is stored and can be retrieved.""" from azure.cli.core import CommandIndex + from azure.cli.core._session import INDEX test_help_data = { 'root': { @@ -558,7 +559,7 @@ def test_help_cache_storage_and_retrieval(self): command_index = CommandIndex(self.test_cli) command_index.set_help_index(test_help_data) - retrieved = command_index.get_help_index() + retrieved = INDEX.get('helpIndex') self.assertIsNotNone(retrieved) self.assertIn('root', retrieved) From af1681515910b446349e19a634736aa261f39b29 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Feb 2026 10:40:39 +1100 Subject: [PATCH 33/38] fix: add --help parameters for telemetry on cached path --- src/azure-cli-core/azure/cli/core/commands/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index f9c0da3597e..237a8b3e583 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -743,8 +743,8 @@ def _try_show_cached_help(self, command_preserve_casing): if help_index and 'root' in help_index: # Display cached help using the help system self.help.show_cached_help(help_index['root'], 'root') - telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) - telemetry.set_success(summary='cached-help') + 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 From 638d6e5d40f456312007f08e5321d46f1ae2de9c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Feb 2026 11:13:18 +1100 Subject: [PATCH 34/38] refactor: helper method to extract asci codes --- src/azure-cli-core/azure/cli/core/_help.py | 27 +++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index b9129276286..0b0ba927baa 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -46,6 +46,22 @@ """ +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). @@ -53,19 +69,14 @@ def get_help_item_tags(item): """ tags = [] if hasattr(item, 'deprecate_info') and item.deprecate_info: - # Get plain text tag by accessing the underlying message tag_obj = item.deprecate_info.tag - # ColorizedString's _message has the plain text, str() would include ANSI codes - tag_text = tag_obj._message if hasattr(tag_obj, '_message') else str(tag_obj) - tags.append(tag_text) + tags.append(_get_tag_plain_text(tag_obj)) if hasattr(item, 'preview_info') and item.preview_info: tag_obj = item.preview_info.tag - tag_text = tag_obj._message if hasattr(tag_obj, '_message') else str(tag_obj) - tags.append(tag_text) + tags.append(_get_tag_plain_text(tag_obj)) if hasattr(item, 'experimental_info') and item.experimental_info: tag_obj = item.experimental_info.tag - tag_text = tag_obj._message if hasattr(tag_obj, '_message') else str(tag_obj) - tags.append(tag_text) + tags.append(_get_tag_plain_text(tag_obj)) return ' '.join(tags) From 5ed5a0af42db80ac74c3e43f809233c2398e83a6 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Feb 2026 11:49:52 +1100 Subject: [PATCH 35/38] refactor: add logic to show/not-show welcome msg --- src/azure-cli-core/azure/cli/core/_help.py | 12 +++++++++--- .../azure/cli/core/commands/__init__.py | 12 ++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 0b0ba927baa..40998a57c98 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -371,15 +371,21 @@ def _print_cached_help_section(items, header, max_line_len): ) _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) - def show_cached_help(self, help_data, command_path='root'): - """Display help from cached help index without loading modules.""" + def show_cached_help(self, help_data, command_path='root', args=None): + """Display help from cached help index without loading modules. + Args: + help_data: Cached help data dictionary + command_path: Path to command (e.g., 'root' or 'vm disk') + 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') - print(WELCOME_MESSAGE) + if not args: + print(WELCOME_MESSAGE) if command_path == 'root': print("\nGroup") diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 237a8b3e583..c22dd4d8907 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -519,7 +519,7 @@ def execute(self, args): args = _pre_command_table_create(self.cli_ctx, args) if self._should_use_command_index() and self._is_top_level_help_request(args): - result = self._try_show_cached_help(command_preserve_casing) + result = self._try_show_cached_help(command_preserve_casing, args) if result: return result @@ -715,7 +715,11 @@ def _extract_parameter_names(args): @staticmethod def _is_top_level_help_request(args): - """Determine if this is a top-level help request (az --help or just az).""" + """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 @@ -731,7 +735,7 @@ 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 _try_show_cached_help(self, command_preserve_casing): + 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. @@ -742,7 +746,7 @@ def _try_show_cached_help(self, command_preserve_casing): if help_index and 'root' in help_index: # Display cached help using the help system - self.help.show_cached_help(help_index['root'], 'root') + self.help.show_cached_help(help_index['root'], 'root', 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) From a5568414da9e70064510c6936910089a8d390ae0 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 20 Feb 2026 11:32:27 +1100 Subject: [PATCH 36/38] refactor: remove redundant oot nesting --- src/azure-cli-core/azure/cli/core/__init__.py | 2 +- src/azure-cli-core/azure/cli/core/commands/__init__.py | 6 +++--- src/azure-cli-core/azure/cli/core/tests/test_help.py | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 35b557d72f2..2ac928c1770 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -552,7 +552,7 @@ def _cache_help_index(self, command_index): groups, commands = extract_help_index_data(help_file) if groups or commands: - help_index_data = {'root': {'groups': groups, 'commands': 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)) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index c22dd4d8907..6f70b6938be 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -744,9 +744,9 @@ def _try_show_cached_help(self, command_preserve_casing, args): command_index = CommandIndex(self.cli_ctx) help_index = command_index.get_help_index() - if help_index and 'root' in help_index: + if help_index: # Display cached help using the help system - self.help.show_cached_help(help_index['root'], 'root', args) + self.help.show_cached_help(help_index, 'root', 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) @@ -765,7 +765,7 @@ def _save_help_to_command_index(self, subparser): groups, commands = extract_help_index_data(help_file) # Store in the command index - help_index_data = {'root': {'groups': groups, 'commands': commands}} + 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)) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_help.py b/src/azure-cli-core/azure/cli/core/tests/test_help.py index e9f623a75fd..a15749f384d 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_help.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_help.py @@ -563,10 +563,10 @@ def test_help_cache_storage_and_retrieval(self): self.assertIsNotNone(retrieved) self.assertIn('root', retrieved) - self.assertIn('groups', retrieved['root']) - self.assertIn('commands', retrieved['root']) - self.assertEqual(retrieved['root']['groups']['test-group']['summary'], 'Test group summary') - self.assertEqual(retrieved['root']['commands']['test-cmd']['summary'], 'Test command summary') + self.assertIn('groups', retrieved) + self.assertIn('commands', retrieved) + self.assertEqual(retrieved['groups']['test-group']['summary'], 'Test group summary') + self.assertEqual(retrieved['commands']['test-cmd']['summary'], 'Test command summary') def test_help_cache_invalidation(self): """Test that cache is invalidated correctly.""" From 801e89df011e128c6fb165c37064e2c6f8850a47 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 20 Feb 2026 11:46:43 +1100 Subject: [PATCH 37/38] fix: change test structure to match new structure --- .../azure/cli/core/tests/test_help.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_help.py b/src/azure-cli-core/azure/cli/core/tests/test_help.py index a15749f384d..3b45040be86 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_help.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_help.py @@ -546,13 +546,11 @@ def test_help_cache_storage_and_retrieval(self): from azure.cli.core._session import INDEX test_help_data = { - 'root': { - 'groups': { - 'test-group': {'summary': 'Test group summary', 'tags': '[Preview]'} - }, - 'commands': { - 'test-cmd': {'summary': 'Test command summary', 'tags': ''} - } + 'groups': { + 'test-group': {'summary': 'Test group summary', 'tags': '[Preview]'} + }, + 'commands': { + 'test-cmd': {'summary': 'Test command summary', 'tags': ''} } } @@ -562,7 +560,6 @@ def test_help_cache_storage_and_retrieval(self): retrieved = INDEX.get('helpIndex') self.assertIsNotNone(retrieved) - self.assertIn('root', retrieved) self.assertIn('groups', retrieved) self.assertIn('commands', retrieved) self.assertEqual(retrieved['groups']['test-group']['summary'], 'Test group summary') From 5a30f5f7aef762060d687dab4e56c4fd971df623 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 20 Feb 2026 13:36:58 +1100 Subject: [PATCH 38/38] refactor: remove redundant oot conditionals --- src/azure-cli-core/azure/cli/core/_help.py | 14 ++++---------- .../azure/cli/core/commands/__init__.py | 2 +- .../azure/cli/core/tests/test_help.py | 2 +- 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 40998a57c98..e28f82ca980 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -371,12 +371,11 @@ def _print_cached_help_section(items, header, max_line_len): ) _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) - def show_cached_help(self, help_data, command_path='root', args=None): + 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 - command_path: Path to command (e.g., 'root' or 'vm disk') args: Original command line args. If empty/None, shows welcome banner. """ ran_before = self.cli_ctx.config.getboolean('core', 'first_run', fallback=False) @@ -387,12 +386,8 @@ def show_cached_help(self, help_data, command_path='root', args=None): if not args: print(WELCOME_MESSAGE) - if command_path == 'root': - print("\nGroup") - print(" az") - else: - print("\nGroup") - print(f" az {command_path}") + print("\nGroup") + print(" az") groups_data = help_data.get('groups', {}) commands_data = help_data.get('commands', {}) @@ -409,8 +404,7 @@ def show_cached_help(self, help_data, command_path='root', args=None): # Use same az find message as non-cached path print() # Blank line before the message - command = '' if command_path == 'root' else command_path - self._print_az_find_message(command) + self._print_az_find_message('') from azure.cli.core.util import show_updates_available show_updates_available(new_line_after=True) diff --git a/src/azure-cli-core/azure/cli/core/commands/__init__.py b/src/azure-cli-core/azure/cli/core/commands/__init__.py index 6f70b6938be..d3a4d0604ac 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -746,7 +746,7 @@ def _try_show_cached_help(self, command_preserve_casing, args): if help_index: # Display cached help using the help system - self.help.show_cached_help(help_index, 'root', args) + 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) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_help.py b/src/azure-cli-core/azure/cli/core/tests/test_help.py index 3b45040be86..15528e71440 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_help.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_help.py @@ -605,7 +605,7 @@ def test_show_cached_help_output(self): sys.stdout = captured_output try: - help_obj.show_cached_help(test_help_data, command_path='root') + help_obj.show_cached_help(test_help_data) output = captured_output.getvalue() self.assertIn('Subgroups:', output)