From 348ccf70e08f0fd5f3addcea44e4648a97e2f67c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 13 Jan 2026 16:33:30 +1100 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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 \"")