diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 0783ed923ac..9c82bcb4345 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -429,6 +429,19 @@ 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) + sys.exit(0) + if use_command_index: command_index = CommandIndex(self.cli_ctx) index_result = command_index.get(args) @@ -496,8 +509,163 @@ 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 + 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) + + 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_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 + + # 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) + + 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) + + # 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.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 data: %s", ex) @staticmethod def _sort_command_loaders(command_loaders): @@ -567,6 +735,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 +804,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 +833,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 +843,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 +864,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..f02021c1986 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,58 @@ 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) + + # 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)) + 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)