From 348ccf70e08f0fd5f3addcea44e4648a97e2f67c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 13 Jan 2026 16:33:30 +1100 Subject: [PATCH 1/8] 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 0783ed923ac..bcb713e6fc6 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -429,6 +429,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) @@ -496,8 +510,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): @@ -567,6 +645,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. @@ -635,6 +714,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. @@ -645,6 +743,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(): @@ -654,8 +753,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): @@ -672,6 +774,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 696b6093f5d..4750612728f 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 735816fe93217335dfbc967f970d5abb3a9f62d3 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 13 Jan 2026 17:25:01 +1100 Subject: [PATCH 2/8] 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 bcb713e6fc6..212bf17378f 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -440,7 +440,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: @@ -530,18 +529,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 @@ -563,17 +592,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 4750612728f..f02021c1986 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 13bca4ca953d3caaead0a072342bb76292f8804e Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 14 Jan 2026 11:06:14 +1100 Subject: [PATCH 3/8] 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 212bf17378f..dd3b9c9c070 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -519,6 +519,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) @@ -537,38 +543,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 542346a113560a729b5c1b6088a77199d4e2269f Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 14 Jan 2026 11:43:12 +1100 Subject: [PATCH 4/8] 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 dd3b9c9c070..9c82bcb4345 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -535,57 +535,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 8bac05778cc38c703920a6b3b1c2865cff07412f Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 21 Jan 2026 15:57:36 +1100 Subject: [PATCH 5/8] 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 9c82bcb4345..ac8a49addce 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -430,17 +430,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) @@ -516,7 +533,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 @@ -535,15 +552,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): @@ -615,55 +637,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 3f5550820f2a139cfc388a3e477b49ac8581bba6 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 21 Jan 2026 16:38:27 +1100 Subject: [PATCH 6/8] 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 ac8a49addce..e1868388f36 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -661,7 +661,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) @@ -669,6 +669,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'): @@ -696,17 +697,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 = {} @@ -724,10 +735,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 16641ce7a14152149a9ab9b50c24562eed542f07 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 22 Jan 2026 12:18:16 +1100 Subject: [PATCH 7/8] 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 e1868388f36..1a3a7193f41 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -431,7 +431,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: @@ -451,14 +451,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) @@ -1260,3 +1252,4 @@ def get_default_cli(): logging_cls=AzCliLogging, output_cls=AzOutputProducer, help_cls=AzCliHelp) + From 8f97308fba3f90f4713a1b3d36b7df8a22e8a608 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 22 Jan 2026 12:22:52 +1100 Subject: [PATCH 8/8] 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 1a3a7193f41..d10b333c4c5 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -653,7 +653,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) @@ -661,7 +661,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'): @@ -689,27 +688,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 = {} @@ -727,14 +716,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())