Skip to content

Commit 7c52d7d

Browse files
{Core} Use commandIndex caching to improve performance of top-level help commands (#32637)
1 parent 1579dc1 commit 7c52d7d

File tree

4 files changed

+426
-4
lines changed

4 files changed

+426
-4
lines changed

src/azure-cli-core/azure/cli/core/__init__.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ def _get_extension_suppressions(mod_loaders):
452452
command_index = None
453453
# Set fallback=False to turn off command index in case of regression
454454
use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True)
455+
455456
if use_command_index:
456457
command_index = CommandIndex(self.cli_ctx)
457458
index_result = command_index.get(args)
@@ -525,9 +526,39 @@ def _get_extension_suppressions(mod_loaders):
525526

526527
if use_command_index:
527528
command_index.update(self.command_table)
529+
self._cache_help_index(command_index)
528530

529531
return self.command_table
530532

533+
def _display_cached_help(self, help_data, command_path='root'):
534+
"""Display help from cached help index without loading modules."""
535+
# Delegate to the help system for consistent formatting
536+
self.cli_ctx.invocation.help.show_cached_help(help_data, command_path)
537+
538+
def _cache_help_index(self, command_index):
539+
"""Cache help summary for top-level (root) help only."""
540+
try:
541+
from azure.cli.core.parser import AzCliCommandParser
542+
from azure.cli.core._help import CliGroupHelpFile, extract_help_index_data
543+
544+
parser = AzCliCommandParser(self.cli_ctx)
545+
parser.load_command_table(self)
546+
547+
subparser = parser.subparsers.get(tuple())
548+
if subparser:
549+
help_file = CliGroupHelpFile(self.cli_ctx.invocation.help, '', subparser)
550+
help_file.load(subparser)
551+
552+
groups, commands = extract_help_index_data(help_file)
553+
554+
if groups or commands:
555+
help_index_data = {'groups': groups, 'commands': commands}
556+
command_index.set_help_index(help_index_data)
557+
logger.debug("Cached top-level help with %d groups and %d commands", len(groups), len(commands))
558+
559+
except Exception as ex: # pylint: disable=broad-except
560+
logger.debug("Failed to cache help data: %s", ex)
561+
531562
@staticmethod
532563
def _sort_command_loaders(command_loaders):
533564
module_command_loaders = []
@@ -698,6 +729,7 @@ class CommandIndex:
698729
_COMMAND_INDEX = 'commandIndex'
699730
_COMMAND_INDEX_VERSION = 'version'
700731
_COMMAND_INDEX_CLOUD_PROFILE = 'cloudProfile'
732+
_HELP_INDEX = 'helpIndex'
701733

702734
def __init__(self, cli_ctx=None):
703735
"""Class to manage command index.
@@ -711,6 +743,16 @@ def __init__(self, cli_ctx=None):
711743
self.cloud_profile = cli_ctx.cloud.profile
712744
self.cli_ctx = cli_ctx
713745

746+
def _is_index_valid(self):
747+
"""Check if the command index version and cloud profile are valid.
748+
749+
:return: True if index is valid, False otherwise
750+
"""
751+
index_version = self.INDEX.get(self._COMMAND_INDEX_VERSION)
752+
cloud_profile = self.INDEX.get(self._COMMAND_INDEX_CLOUD_PROFILE)
753+
return (index_version and index_version == self.version and
754+
cloud_profile and cloud_profile == self.cloud_profile)
755+
714756
def _get_top_level_completion_commands(self):
715757
"""Get top-level command names for tab completion optimization.
716758
@@ -736,10 +778,7 @@ def get(self, args):
736778
"""
737779
# If the command index version or cloud profile doesn't match those of the current command,
738780
# invalidate the command index.
739-
index_version = self.INDEX[self._COMMAND_INDEX_VERSION]
740-
cloud_profile = self.INDEX[self._COMMAND_INDEX_CLOUD_PROFILE]
741-
if not (index_version and index_version == self.version and
742-
cloud_profile and cloud_profile == self.cloud_profile):
781+
if not self._is_index_valid():
743782
logger.debug("Command index version or cloud profile is invalid or doesn't match the current command.")
744783
self.invalidate()
745784
return None
@@ -786,6 +825,28 @@ def get(self, args):
786825

787826
return None
788827

828+
def get_help_index(self):
829+
"""Get the help index for top-level help display.
830+
831+
:return: Dictionary mapping top-level commands to their short summaries, or None if not available
832+
"""
833+
if not self._is_index_valid():
834+
return None
835+
836+
help_index = self.INDEX.get(self._HELP_INDEX, {})
837+
if help_index:
838+
logger.debug("Using cached help index with %d entries", len(help_index))
839+
return help_index
840+
841+
return None
842+
843+
def set_help_index(self, help_data):
844+
"""Set the help index data.
845+
846+
:param help_data: Help index data structure containing groups and commands
847+
"""
848+
self.INDEX[self._HELP_INDEX] = help_data
849+
789850
def update(self, command_table):
790851
"""Update the command index according to the given command table.
791852
@@ -805,6 +866,7 @@ def update(self, command_table):
805866
module_name = command.loader.__module__
806867
if module_name not in index[top_command]:
807868
index[top_command].append(module_name)
869+
808870
elapsed_time = timeit.default_timer() - start_time
809871
self.INDEX[self._COMMAND_INDEX] = index
810872
logger.debug("Updated command index in %.3f seconds.", elapsed_time)
@@ -823,6 +885,7 @@ def invalidate(self):
823885
self.INDEX[self._COMMAND_INDEX_VERSION] = ""
824886
self.INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = ""
825887
self.INDEX[self._COMMAND_INDEX] = {}
888+
self.INDEX[self._HELP_INDEX] = {}
826889
logger.debug("Command index has been invalidated.")
827890

828891

src/azure-cli-core/azure/cli/core/_help.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,73 @@
4646
"""
4747

4848

49+
def _get_tag_plain_text(tag_obj):
50+
"""Extract plain text from a tag object (typically ColorizedString).
51+
52+
ColorizedString objects store plain text in _message and add ANSI codes via __str__.
53+
For caching, we need plain text only. This function safely extracts it.
54+
55+
:param tag_obj: Tag object (ColorizedString or other)
56+
:return: Plain text string without ANSI codes
57+
"""
58+
# ColorizedString stores plain text in _message attribute
59+
if hasattr(tag_obj, '_message'):
60+
return tag_obj._message # pylint: disable=protected-access
61+
# Fallback for non-ColorizedString objects
62+
return str(tag_obj)
63+
64+
65+
def get_help_item_tags(item):
66+
"""Extract status tags from a help item (group or command).
67+
68+
Returns a space-separated string of plain text tags like '[Deprecated] [Preview]'.
69+
"""
70+
tags = []
71+
if hasattr(item, 'deprecate_info') and item.deprecate_info:
72+
tag_obj = item.deprecate_info.tag
73+
tags.append(_get_tag_plain_text(tag_obj))
74+
if hasattr(item, 'preview_info') and item.preview_info:
75+
tag_obj = item.preview_info.tag
76+
tags.append(_get_tag_plain_text(tag_obj))
77+
if hasattr(item, 'experimental_info') and item.experimental_info:
78+
tag_obj = item.experimental_info.tag
79+
tags.append(_get_tag_plain_text(tag_obj))
80+
return ' '.join(tags)
81+
82+
83+
def extract_help_index_data(help_file):
84+
"""Extract groups and commands from help file children for caching.
85+
86+
Processes help file children and builds dictionaries of groups and commands
87+
with their summaries and tags for top-level help display.
88+
89+
:param help_file: Help file with loaded children
90+
:return: Tuple of (groups_dict, commands_dict)
91+
"""
92+
groups = {}
93+
commands = {}
94+
95+
for child in help_file.children:
96+
if hasattr(child, 'name') and hasattr(child, 'short_summary'):
97+
child_name = child.name
98+
# Only include top-level items (no spaces in name)
99+
if ' ' in child_name:
100+
continue
101+
102+
tags = get_help_item_tags(child)
103+
item_data = {
104+
'summary': child.short_summary,
105+
'tags': tags
106+
}
107+
108+
if child.type == 'group':
109+
groups[child_name] = item_data
110+
else:
111+
commands[child_name] = item_data
112+
113+
return groups, commands
114+
115+
49116
# PrintMixin class to decouple printing functionality from AZCLIHelp class.
50117
# Most of these methods override print methods in CLIHelp
51118
class CLIPrintMixin(CLIHelp):
@@ -241,6 +308,107 @@ def update_loaders_with_help_file_contents(self, nouns):
241308
def update_examples(help_file):
242309
pass
243310

311+
@staticmethod
312+
def _colorize_tag(tag_text, enable_color):
313+
"""Add color to a plain text tag based on its content."""
314+
if not enable_color or not tag_text:
315+
return tag_text
316+
317+
from knack.util import color_map
318+
319+
tag_lower = tag_text.lower()
320+
if 'preview' in tag_lower:
321+
color = color_map['preview']
322+
elif 'experimental' in tag_lower:
323+
color = color_map['experimental']
324+
elif 'deprecat' in tag_lower:
325+
color = color_map['deprecation']
326+
else:
327+
return tag_text
328+
329+
return f"{color}{tag_text}{color_map['reset']}"
330+
331+
@staticmethod
332+
def _build_cached_help_items(data, enable_color=False):
333+
"""Process help items from cache and return list with calculated line lengths."""
334+
from knack.help import _get_line_len
335+
items = []
336+
for name in sorted(data.keys()):
337+
item = data[name]
338+
plain_tags = item.get('tags', '')
339+
340+
# Colorize each tag individually if needed
341+
if plain_tags and enable_color:
342+
# Split multiple tags and colorize each
343+
tag_parts = plain_tags.split()
344+
colored_tags = ' '.join(AzCliHelp._colorize_tag(tag, enable_color) for tag in tag_parts)
345+
else:
346+
colored_tags = plain_tags
347+
348+
tags_len = len(plain_tags)
349+
line_len = _get_line_len(name, tags_len)
350+
items.append((name, colored_tags, line_len, item.get('summary', '')))
351+
return items
352+
353+
@staticmethod
354+
def _print_cached_help_section(items, header, max_line_len):
355+
"""Display cached help items with consistent formatting."""
356+
from knack.help import FIRST_LINE_PREFIX, _get_hanging_indent, _get_padding_len
357+
if not items:
358+
return
359+
print(f"\n{header}")
360+
indent = 1
361+
LINE_FORMAT = '{name}{padding}{tags}{separator}{summary}'
362+
for name, tags, line_len, summary in items:
363+
layout = {'line_len': line_len, 'tags': tags}
364+
padding = ' ' * _get_padding_len(max_line_len, layout)
365+
line = LINE_FORMAT.format(
366+
name=name,
367+
padding=padding,
368+
tags=tags,
369+
separator=FIRST_LINE_PREFIX if summary else '',
370+
summary=summary
371+
)
372+
_print_indent(line, indent, _get_hanging_indent(max_line_len, indent))
373+
374+
def show_cached_help(self, help_data, args=None):
375+
"""Display help from cached help index without loading modules.
376+
377+
Args:
378+
help_data: Cached help data dictionary
379+
args: Original command line args. If empty/None, shows welcome banner.
380+
"""
381+
ran_before = self.cli_ctx.config.getboolean('core', 'first_run', fallback=False)
382+
if not ran_before:
383+
print(PRIVACY_STATEMENT)
384+
self.cli_ctx.config.set_value('core', 'first_run', 'yes')
385+
386+
if not args:
387+
print(WELCOME_MESSAGE)
388+
389+
print("\nGroup")
390+
print(" az")
391+
392+
groups_data = help_data.get('groups', {})
393+
commands_data = help_data.get('commands', {})
394+
395+
groups_items = self._build_cached_help_items(groups_data, self.cli_ctx.enable_color)
396+
commands_items = self._build_cached_help_items(commands_data, self.cli_ctx.enable_color)
397+
max_line_len = max(
398+
(line_len for _, _, line_len, _ in groups_items + commands_items),
399+
default=0
400+
)
401+
402+
self._print_cached_help_section(groups_items, "Subgroups:", max_line_len)
403+
self._print_cached_help_section(commands_items, "Commands:", max_line_len)
404+
405+
# Use same az find message as non-cached path
406+
print() # Blank line before the message
407+
self._print_az_find_message('')
408+
409+
from azure.cli.core.util import show_updates_available
410+
show_updates_available(new_line_after=True)
411+
244412

245413
class CliHelpFile(KnackHelpFile):
246414

0 commit comments

Comments
 (0)