diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 16396007d95..2ac928c1770 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -452,6 +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) @@ -525,9 +526,39 @@ def _get_extension_suppressions(mod_loaders): if use_command_index: command_index.update(self.command_table) + self._cache_help_index(command_index) return self.command_table + def _display_cached_help(self, help_data, command_path='root'): + """Display help from cached help index without loading modules.""" + # Delegate to the help system for consistent formatting + self.cli_ctx.invocation.help.show_cached_help(help_data, command_path) + + def _cache_help_index(self, command_index): + """Cache help summary for top-level (root) help only.""" + try: + from azure.cli.core.parser import AzCliCommandParser + from azure.cli.core._help import CliGroupHelpFile, extract_help_index_data + + parser = AzCliCommandParser(self.cli_ctx) + parser.load_command_table(self) + + subparser = parser.subparsers.get(tuple()) + if subparser: + help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser) + help_file.load(subparser) + + groups, commands = extract_help_index_data(help_file) + + if groups or commands: + help_index_data = {'groups': groups, 'commands': commands} + command_index.set_help_index(help_index_data) + logger.debug("Cached top-level help with %d groups and %d commands", len(groups), len(commands)) + + except Exception as ex: # pylint: disable=broad-except + logger.debug("Failed to cache help data: %s", ex) + @staticmethod def _sort_command_loaders(command_loaders): module_command_loaders = [] @@ -698,6 +729,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. @@ -711,6 +743,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. @@ -736,10 +778,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 @@ -786,6 +825,28 @@ 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 + """ + if not self._is_index_valid(): + return None + + help_index = self.INDEX.get(self._HELP_INDEX, {}) + if help_index: + logger.debug("Using cached help index with %d entries", len(help_index)) + return help_index + + return None + + def set_help_index(self, help_data): + """Set the help index data. + + :param help_data: Help index data structure containing groups and commands + """ + self.INDEX[self._HELP_INDEX] = help_data + def update(self, command_table): """Update the command index according to the given command table. @@ -805,6 +866,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 logger.debug("Updated command index in %.3f seconds.", elapsed_time) @@ -823,6 +885,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/_help.py b/src/azure-cli-core/azure/cli/core/_help.py index 9f192da76c9..e28f82ca980 100644 --- a/src/azure-cli-core/azure/cli/core/_help.py +++ b/src/azure-cli-core/azure/cli/core/_help.py @@ -46,6 +46,73 @@ """ +def _get_tag_plain_text(tag_obj): + """Extract plain text from a tag object (typically ColorizedString). + + ColorizedString objects store plain text in _message and add ANSI codes via __str__. + For caching, we need plain text only. This function safely extracts it. + + :param tag_obj: Tag object (ColorizedString or other) + :return: Plain text string without ANSI codes + """ + # ColorizedString stores plain text in _message attribute + if hasattr(tag_obj, '_message'): + return tag_obj._message # pylint: disable=protected-access + # Fallback for non-ColorizedString objects + return str(tag_obj) + + +def get_help_item_tags(item): + """Extract status tags from a help item (group or command). + + Returns a space-separated string of plain text tags like '[Deprecated] [Preview]'. + """ + tags = [] + if hasattr(item, 'deprecate_info') and item.deprecate_info: + tag_obj = item.deprecate_info.tag + tags.append(_get_tag_plain_text(tag_obj)) + if hasattr(item, 'preview_info') and item.preview_info: + tag_obj = item.preview_info.tag + tags.append(_get_tag_plain_text(tag_obj)) + if hasattr(item, 'experimental_info') and item.experimental_info: + tag_obj = item.experimental_info.tag + tags.append(_get_tag_plain_text(tag_obj)) + return ' '.join(tags) + + +def extract_help_index_data(help_file): + """Extract groups and commands from help file children for caching. + + Processes help file children and builds dictionaries of groups and commands + with their summaries and tags for top-level help display. + + :param help_file: Help file with loaded children + :return: Tuple of (groups_dict, commands_dict) + """ + groups = {} + commands = {} + + for child in help_file.children: + if hasattr(child, 'name') and hasattr(child, 'short_summary'): + child_name = child.name + # Only include top-level items (no spaces in name) + if ' ' in child_name: + continue + + tags = get_help_item_tags(child) + item_data = { + 'summary': child.short_summary, + 'tags': tags + } + + if child.type == 'group': + groups[child_name] = item_data + else: + commands[child_name] = item_data + + return groups, commands + + # PrintMixin class to decouple printing functionality from AZCLIHelp class. # Most of these methods override print methods in CLIHelp class CLIPrintMixin(CLIHelp): @@ -241,6 +308,107 @@ def update_loaders_with_help_file_contents(self, nouns): def update_examples(help_file): pass + @staticmethod + def _colorize_tag(tag_text, enable_color): + """Add color to a plain text tag based on its content.""" + if not enable_color or not tag_text: + return tag_text + + from knack.util import color_map + + tag_lower = tag_text.lower() + if 'preview' in tag_lower: + color = color_map['preview'] + elif 'experimental' in tag_lower: + color = color_map['experimental'] + elif 'deprecat' in tag_lower: + color = color_map['deprecation'] + else: + return tag_text + + return f"{color}{tag_text}{color_map['reset']}" + + @staticmethod + def _build_cached_help_items(data, enable_color=False): + """Process help items from cache and return list with calculated line lengths.""" + from knack.help import _get_line_len + items = [] + for name in sorted(data.keys()): + item = data[name] + plain_tags = item.get('tags', '') + + # Colorize each tag individually if needed + if plain_tags and enable_color: + # Split multiple tags and colorize each + tag_parts = plain_tags.split() + colored_tags = ' '.join(AzCliHelp._colorize_tag(tag, enable_color) for tag in tag_parts) + else: + colored_tags = plain_tags + + tags_len = len(plain_tags) + line_len = _get_line_len(name, tags_len) + items.append((name, colored_tags, line_len, item.get('summary', ''))) + return items + + @staticmethod + def _print_cached_help_section(items, header, max_line_len): + """Display cached help items with consistent formatting.""" + from knack.help import FIRST_LINE_PREFIX, _get_hanging_indent, _get_padding_len + if not items: + return + print(f"\n{header}") + indent = 1 + LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}' + for name, tags, line_len, summary in items: + layout = {'line_len': line_len, 'tags': tags} + padding = ' ' * _get_padding_len(max_line_len, layout) + line = LINE_FORMAT.format( + name=name, + padding=padding, + tags=tags, + separator=FIRST_LINE_PREFIX if summary else '', + summary=summary + ) + _print_indent(line, indent, _get_hanging_indent(max_line_len, indent)) + + def show_cached_help(self, help_data, args=None): + """Display help from cached help index without loading modules. + + Args: + help_data: Cached help data dictionary + args: Original command line args. If empty/None, shows welcome banner. + """ + ran_before = self.cli_ctx.config.getboolean('core', 'first_run', fallback=False) + if not ran_before: + print(PRIVACY_STATEMENT) + self.cli_ctx.config.set_value('core', 'first_run', 'yes') + + if not args: + print(WELCOME_MESSAGE) + + print("\nGroup") + print(" az") + + groups_data = help_data.get('groups', {}) + commands_data = help_data.get('commands', {}) + + groups_items = self._build_cached_help_items(groups_data, self.cli_ctx.enable_color) + commands_items = self._build_cached_help_items(commands_data, self.cli_ctx.enable_color) + max_line_len = max( + (line_len for _, _, line_len, _ in groups_items + commands_items), + default=0 + ) + + self._print_cached_help_section(groups_items, "Subgroups:", max_line_len) + self._print_cached_help_section(commands_items, "Commands:", max_line_len) + + # Use same az find message as non-cached path + print() # Blank line before the message + self._print_az_find_message('') + + from azure.cli.core.util import show_updates_available + show_updates_available(new_line_after=True) + class CliHelpFile(KnackHelpFile): diff --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..d3a4d0604ac 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,11 @@ def execute(self, args): command_preserve_casing = roughly_parse_command_with_casing(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, args) + if result: + return result + self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) self.commands_loader.load_command_table(args) self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, @@ -579,6 +584,14 @@ def execute(self, args): subparser = self.parser.subparsers[tuple()] self.help.show_welcome(subparser) + use_command_index = self._should_use_command_index() + logger.debug("About to cache help data, use_command_index=%s", use_command_index) + if use_command_index: + try: + self._save_help_to_command_index(subparser) + except Exception as ex: # pylint: disable=broad-except + logger.debug("Failed to cache help data: %s", ex) + # TODO: No event in base with which to target telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing) telemetry.set_success(summary='welcome') @@ -700,6 +713,63 @@ 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 both 'az' with no args and 'az --help' so we can use + cached data without loading all modules. + """ + if not args: + return True + + for arg in args: + if arg in ('--help', '-h', 'help'): + return True + if not arg.startswith('-'): + return False + + return False + + def _should_use_command_index(self): + """Check if command index optimization is enabled.""" + return self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) + + def _try_show_cached_help(self, command_preserve_casing, args): + """Try to show cached help for top-level help request. + + Returns CommandResultItem if cached help was shown, None otherwise. + """ + from azure.cli.core import CommandIndex + command_index = CommandIndex(self.cli_ctx) + help_index = command_index.get_help_index() + + if help_index: + # Display cached help using the help system + self.help.show_cached_help(help_index, args) + telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing, parameters=['--help']) + telemetry.set_success(summary='show help') + return CommandResultItem(None, exit_code=0) + + return None + + def _save_help_to_command_index(self, subparser): + """Extract help data from parser and save to command index for future fast access.""" + from azure.cli.core import CommandIndex + from azure.cli.core._help import CliGroupHelpFile, extract_help_index_data + + command_index = CommandIndex(self.cli_ctx) + help_file = CliGroupHelpFile(self.help, '', subparser) + help_file.load(subparser) + + groups, commands = extract_help_index_data(help_file) + + # Store in the command index + help_index_data = {'groups': groups, 'commands': commands} + if groups or commands: + command_index.set_help_index(help_index_data) + logger.debug("Cached %d groups and %d commands for fast access", len(groups), len(commands)) + def _run_job(self, expanded_arg, cmd_copy): params = self._filter_params(expanded_arg) try: 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..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 @@ -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'] = """ @@ -498,6 +502,123 @@ 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 + from azure.cli.core._session import INDEX + + test_help_data = { + '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 = INDEX.get('helpIndex') + + self.assertIsNotNone(retrieved) + 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 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) + 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: