Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
f37bb16
feature: caching top-level help
DanielMicrosoft Jan 13, 2026
fa0b15a
feature: adjustment help formatting
DanielMicrosoft Jan 13, 2026
1316bdd
fix: adjust help printout formatting
DanielMicrosoft Jan 14, 2026
e690bb1
fix: adjust alignment of printout
DanielMicrosoft Jan 14, 2026
dac5a15
feature: extend concept to module/command level help
DanielMicrosoft Jan 21, 2026
5cc633f
feature: extend concept to nested commands
DanielMicrosoft Jan 21, 2026
0f08a37
fix: correct operator precedence in help fast path and remove no-args…
DanielMicrosoft Jan 22, 2026
128ff24
Revert "feature: extend concept to nested commands"
DanielMicrosoft Jan 22, 2026
16d1c57
refactor: remove module-level help caching
DanielMicrosoft Feb 12, 2026
95200f6
fix: fix top-level help to match test assertions
DanielMicrosoft Feb 12, 2026
b9d4d1a
fix: linting issues
DanielMicrosoft Feb 12, 2026
69c14b1
fix: remove whitespace
DanielMicrosoft Feb 12, 2026
665dd7c
fix: invalidate help cache in tests
DanielMicrosoft Feb 13, 2026
d4ab898
refactor: flake issues
DanielMicrosoft Feb 13, 2026
40ace61
refactor: move methods to _help.py
DanielMicrosoft Feb 13, 2026
47c88ec
refactor: top-level help method
DanielMicrosoft Feb 13, 2026
b0f27b9
fix: remove redundant comments and whitespace issue
DanielMicrosoft Feb 16, 2026
bbd9b92
refactor: remove redundant import
DanielMicrosoft Feb 17, 2026
88611d8
refactor: add help index setter
DanielMicrosoft Feb 17, 2026
73064fe
refactor: refactoring methods for display cached help
DanielMicrosoft Feb 17, 2026
1ece2ec
refactor: refactor print_Az_msg to use same as non-cached path
DanielMicrosoft Feb 17, 2026
d744377
refactor: add helper method to check command index
DanielMicrosoft Feb 17, 2026
250fa8e
refactor: top-level help method return from parent execute method
DanielMicrosoft Feb 17, 2026
f805347
refactor: break out saving help cache from execute method
DanielMicrosoft Feb 17, 2026
a95a272
Refactor: remove trailing whitespace
DanielMicrosoft Feb 17, 2026
35abb63
refactor: change k/v index to match in all paths
DanielMicrosoft Feb 17, 2026
1924c7f
refactor: simplify logic for checking for top-level cmdn
DanielMicrosoft Feb 17, 2026
253aaed
refactor: simplify colorizing tags
DanielMicrosoft Feb 17, 2026
9d8580c
refactor: extract formating method
DanielMicrosoft Feb 17, 2026
3801740
refactor: remove whitespace
DanielMicrosoft Feb 17, 2026
cceacd9
test: add tests for cache help CRUD methods
DanielMicrosoft Feb 17, 2026
14efeb0
test: change to directly access INDEX as cache validation rules fail …
DanielMicrosoft Feb 17, 2026
af16815
fix: add --help parameters for telemetry on cached path
DanielMicrosoft Feb 17, 2026
638d6e5
refactor: helper method to extract asci codes
DanielMicrosoft Feb 18, 2026
5ed5a0a
refactor: add logic to show/not-show welcome msg
DanielMicrosoft Feb 18, 2026
a556841
refactor: remove redundant oot nesting
DanielMicrosoft Feb 20, 2026
801e89d
fix: change test structure to match new structure
DanielMicrosoft Feb 20, 2026
5a30f5f
refactor: remove redundant oot conditionals
DanielMicrosoft Feb 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 67 additions & 4 deletions src/azure-cli-core/azure/cli/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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.
Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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)
Expand All @@ -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.")


Expand Down
168 changes: 168 additions & 0 deletions src/azure-cli-core/azure/cli/core/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):

Expand Down
Loading