From 141000c0fdf5c4fdf5fdce9ae2bffcaf534cca56 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Mar 2026 12:07:35 +1100 Subject: [PATCH 01/26] rebase --- src/azure-cli-core/azure/cli/core/__init__.py | 338 +++++++++++++- src/azure-cli-core/azure/cli/core/_session.py | 6 + .../azure/cli/core/commandIndex.latest.json | 327 ++++++++++++++ .../azure/cli/core/helpIndex.latest.json | 414 ++++++++++++++++++ src/azure-cli-core/azure/cli/core/mock.py | 2 +- .../core/tests/test_command_registration.py | 246 ++++++++++- .../azure/cli/core/tests/test_help.py | 69 ++- src/azure-cli-core/setup.py | 2 +- 8 files changed, 1379 insertions(+), 25 deletions(-) create mode 100644 src/azure-cli-core/azure/cli/core/commandIndex.latest.json create mode 100644 src/azure-cli-core/azure/cli/core/helpIndex.latest.json diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 35777f7264d..997249dda2b 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -8,6 +8,7 @@ import os import sys +import json import timeit import concurrent.futures from concurrent.futures import ThreadPoolExecutor @@ -72,7 +73,7 @@ def __init__(self, **kwargs): register_ids_argument, register_global_subscription_argument, register_global_policy_argument) from azure.cli.core.cloud import get_active_cloud from azure.cli.core.commands.transform import register_global_transforms - from azure.cli.core._session import ACCOUNT, CONFIG, SESSION, INDEX, VERSIONS + from azure.cli.core._session import ACCOUNT, CONFIG, SESSION, INDEX, EXTENSION_INDEX, HELP_INDEX, VERSIONS from azure.cli.core.util import handle_version_update from knack.util import ensure_dir @@ -89,6 +90,8 @@ def __init__(self, **kwargs): CONFIG.load(os.path.join(azure_folder, 'az.json')) SESSION.load(os.path.join(azure_folder, 'az.sess'), max_age=3600) INDEX.load(os.path.join(azure_folder, 'commandIndex.json')) + EXTENSION_INDEX.load(os.path.join(azure_folder, 'extensionIndex.json')) + HELP_INDEX.load(os.path.join(azure_folder, 'helpIndex.json')) VERSIONS.load(os.path.join(azure_folder, 'versionCheck.json')) handle_version_update() @@ -455,6 +458,7 @@ def _get_extension_suppressions(mod_loaders): if use_command_index: command_index = CommandIndex(self.cli_ctx) + lookup_args = command_index._normalize_args_for_index_lookup(args) # pylint: disable=protected-access index_result = command_index.get(args) if index_result: index_modules, index_extensions = index_result @@ -466,7 +470,7 @@ def _get_extension_suppressions(mod_loaders): # Always load modules and extensions, because some of them (like those in # ALWAYS_LOADED_EXTENSIONS) don't expose a command, but hooks into handlers in CLI core - _update_command_table_from_modules(args, index_modules) + _update_command_table_from_modules(lookup_args, index_modules) # The index won't contain suppressed extensions _update_command_table_from_extensions([], index_extensions) @@ -474,7 +478,7 @@ def _get_extension_suppressions(mod_loaders): logger.debug("Loaded %d groups, %d commands.", len(self.command_group_table), len(self.command_table)) from azure.cli.core.util import roughly_parse_command # The index may be outdated. Make sure the command appears in the loaded command table - raw_cmd = roughly_parse_command(args) + raw_cmd = roughly_parse_command(lookup_args) for cmd in self.command_table: if raw_cmd.startswith(cmd): # For commands with positional arguments, the raw command won't match the one in the @@ -730,19 +734,37 @@ class CommandIndex: _COMMAND_INDEX_VERSION = 'version' _COMMAND_INDEX_CLOUD_PROFILE = 'cloudProfile' _HELP_INDEX = 'helpIndex' + _PACKAGED_COMMAND_INDEX_LATEST = 'commandIndex.latest.json' + _PACKAGED_HELP_INDEX_LATEST = 'helpIndex.latest.json' + _LEADING_GLOBAL_OPTS_WITH_VALUE = {'--output', '-o', '--query', '--subscription', '-s', '--tenant', '-t'} + _LEADING_GLOBAL_FLAG_OPTS = {'--debug', '--verbose', '--only-show-errors', '--help', '-h'} def __init__(self, cli_ctx=None): """Class to manage command index. :param cli_ctx: Only needed when `get` or `update` is called. """ - from azure.cli.core._session import INDEX + from azure.cli.core._session import INDEX, EXTENSION_INDEX, HELP_INDEX self.INDEX = INDEX + self.EXTENSION_INDEX = EXTENSION_INDEX + self.HELP_INDEX = HELP_INDEX if cli_ctx: self.version = __version__ self.cloud_profile = cli_ctx.cloud.profile self.cli_ctx = cli_ctx + def _migrate_legacy_help_index(self): + """Migrate help cache from legacy commandIndex.json storage to helpIndex.json.""" + legacy_help_index = self.INDEX.get(self._HELP_INDEX) + if not legacy_help_index: + return None + + logger.debug("Migrating help index cache from commandIndex.json to helpIndex.json") + self.HELP_INDEX[self._HELP_INDEX] = legacy_help_index + # Keep commandIndex.json focused on command routing data. + self.INDEX[self._HELP_INDEX] = {} + return legacy_help_index + def _is_index_valid(self): """Check if the command index version and cloud profile are valid. @@ -753,7 +775,14 @@ def _is_index_valid(self): return (index_version and index_version == self.version and cloud_profile and cloud_profile == self.cloud_profile) - def _get_top_level_completion_commands(self): + def _is_extension_index_valid(self): + """Check if the extension index version and cloud profile are valid.""" + index_version = self.EXTENSION_INDEX.get(self._COMMAND_INDEX_VERSION) + cloud_profile = self.EXTENSION_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, index=None): """Get top-level command names for tab completion optimization. Returns marker and list of top-level commands (e.g., 'network', 'vm') for creating @@ -762,7 +791,7 @@ def _get_top_level_completion_commands(self): :return: tuple of (TOP_LEVEL_COMPLETION_MARKER, list of top-level command names) or None """ - index = self.INDEX.get(self._COMMAND_INDEX) or {} + index = index or self.INDEX.get(self._COMMAND_INDEX) or {} if not index: logger.debug("Command index is empty, will fall back to loading all modules") return None @@ -770,31 +799,257 @@ def _get_top_level_completion_commands(self): logger.debug("Top-level completion: %d commands available", len(top_level_commands)) return TOP_LEVEL_COMPLETION_MARKER, top_level_commands + def _load_packaged_command_index(self): + """Load packaged command index for latest profile if present.""" + file_path = os.path.join(os.path.dirname(__file__), self._PACKAGED_COMMAND_INDEX_LATEST) + if not os.path.isfile(file_path): + return None + + try: + with open(file_path, 'r', encoding='utf-8-sig') as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as ex: + logger.debug("Failed to load packaged command index file '%s': %s", file_path, ex) + return None + + if not isinstance(data, dict): + logger.debug("Packaged command index file '%s' has invalid schema.", file_path) + return None + + return data + + def _load_packaged_help_index(self): + """Load packaged help index for latest profile if present.""" + file_path = os.path.join(os.path.dirname(__file__), self._PACKAGED_HELP_INDEX_LATEST) + if not os.path.isfile(file_path): + return None + + try: + with open(file_path, 'r', encoding='utf-8-sig') as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as ex: + logger.debug("Failed to load packaged help index file '%s': %s", file_path, ex) + return None + + if not isinstance(data, dict): + logger.debug("Packaged help index file '%s' has invalid schema.", file_path) + return None + + if data.get(self._COMMAND_INDEX_VERSION) != self.version: + logger.debug("Packaged help index version doesn't match current CLI version.") + return None + + if data.get(self._COMMAND_INDEX_CLOUD_PROFILE) != self.cloud_profile: + logger.debug("Packaged help index cloud profile doesn't match current cloud profile.") + return None + + help_index = data.get(self._HELP_INDEX) + if not isinstance(help_index, dict) or not help_index: + logger.debug("Packaged help index mapping is missing or empty.") + return None + + return help_index + + def _can_use_packaged_command_index(self, ignore_extensions=False): + """Whether packaged command index can be used safely for this invocation.""" + if self.cloud_profile != 'latest': + return False + + if ignore_extensions: + return True + + # If non-always-loaded extensions are installed, we need a full rebuild to include overrides/extensions. + if self._has_non_always_loaded_extensions(): + return False + + return True + + @staticmethod + def _has_non_always_loaded_extensions(): + """Return True if a non-always-loaded extension is installed.""" + from azure.cli.core.extension import get_extensions, get_extension_modname + + try: + for ext in get_extensions() or []: + ext_mod = get_extension_modname(ext.name, ext.path) + if ext_mod not in ALWAYS_LOADED_EXTENSIONS: + logger.debug("Found installed extension '%s' (%s).", ext.name, ext_mod) + return True + except Exception as ex: # pylint: disable=broad-except + logger.debug("Failed to evaluate installed extensions: %s", ex) + return True + + return False + + def _get_packaged_command_index(self, ignore_extensions=False): + """Get packaged command index mapping if valid for current profile/version.""" + if not self._can_use_packaged_command_index(ignore_extensions=ignore_extensions): + return None + + packaged_index = self._load_packaged_command_index() + if not packaged_index: + return None + + if packaged_index.get(self._COMMAND_INDEX_VERSION) != self.version: + logger.debug("Packaged command index version doesn't match current CLI version.") + return None + + if packaged_index.get(self._COMMAND_INDEX_CLOUD_PROFILE) != self.cloud_profile: + logger.debug("Packaged command index cloud profile doesn't match current cloud profile.") + return None + + index = packaged_index.get(self._COMMAND_INDEX) + if not isinstance(index, dict) or not index: + logger.debug("Packaged command index mapping is missing or empty.") + return None + + logger.debug("Using packaged command index for profile '%s'.", self.cloud_profile) + return index + + @staticmethod + def _blend_command_indices(core_index, extension_index): + """Blend packaged core index with local extension overlay index.""" + blended = {cmd: list(mods) for cmd, mods in (core_index or {}).items()} + for cmd, mods in (extension_index or {}).items(): + if cmd not in blended: + blended[cmd] = [] + for mod in mods: + if mod not in blended[cmd]: + blended[cmd].append(mod) + return blended + + def _get_blended_latest_index(self): + """Get effective index for latest profile by blending core and extension indices.""" + if self.cloud_profile != 'latest': + return None, False, False + + core_index = self._get_packaged_command_index(ignore_extensions=True) + if not core_index: + return None, False, False + + extension_index = {} + extension_index_available = False + has_non_always_loaded_extensions = self._has_non_always_loaded_extensions() + if self._is_extension_index_valid(): + extension_index = self.EXTENSION_INDEX.get(self._COMMAND_INDEX) or {} + extension_index_available = True + else: + if self.EXTENSION_INDEX.get(self._COMMAND_INDEX): + logger.debug("Extension index version or cloud profile is invalid, clearing local extension index.") + self.EXTENSION_INDEX[self._COMMAND_INDEX_VERSION] = "" + self.EXTENSION_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" + self.EXTENSION_INDEX[self._COMMAND_INDEX] = {} + + if extension_index: + logger.debug("Blending packaged core index with local extension index.") + return self._blend_command_indices(core_index, extension_index), extension_index_available, has_non_always_loaded_extensions + + @classmethod + def _normalize_args_for_index_lookup(cls, args): + """Trim leading global options so index lookup can find the top-level command.""" + if not args: + return args + + i = 0 + while i < len(args): + token = args[i] + if token == '--': + return args[i + 1:] + + if not token.startswith('-'): + return args[i:] + + if token.startswith('--'): + opt_name = token.split('=', 1)[0] + if '=' in token: + i += 1 + continue + if opt_name in cls._LEADING_GLOBAL_OPTS_WITH_VALUE: + i += 2 + continue + # Unknown long options are treated as flags here. If invalid, normal parser flow will raise later. + i += 1 + continue + + if token in cls._LEADING_GLOBAL_OPTS_WITH_VALUE: + i += 2 + continue + + if token in cls._LEADING_GLOBAL_FLAG_OPTS: + i += 1 + continue + + # Handle compact short options where value is attached, e.g. -ojson. + if len(token) > 2 and token[:2] in {'-o', '-s', '-t'}: + i += 1 + continue + + # Unknown short options are treated as flags here. + i += 1 + + return [] + def get(self, args): """Get the corresponding module and extension list of a command. :param args: command arguments, like ['network', 'vnet', 'create', '-h'] :return: a tuple containing a list of modules and a list of extensions. """ - # If the command index version or cloud profile doesn't match those of the current command, - # invalidate the command index. - 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 + normalized_args = self._normalize_args_for_index_lookup(args) + top_command = normalized_args[0] if normalized_args else None + + # Resolve effective index. + # For latest profile, blend packaged core index with local extension index. + if self.cloud_profile == 'latest': + force_packaged_for_version = bool(top_command == 'version') + index, extension_index_available, has_non_always_loaded_extensions = self._get_blended_latest_index() + if index is not None: + force_load_all_extensions = has_non_always_loaded_extensions and not extension_index_available and \ + not force_packaged_for_version + result = self._lookup_command_in_index(index, normalized_args, + force_load_all_extensions=force_load_all_extensions) + if result: + return result + + if force_load_all_extensions and normalized_args and not normalized_args[0].startswith('-') and \ + not self.cli_ctx.data['completer_active']: + logger.debug("No match found in blended latest index for '%s'. Loading all extensions.", + normalized_args[0]) + # Load all extensions to resolve extension-only top-level commands without rebuilding all modules. + return [], None + + logger.debug("No match found in blended latest index. Falling back to local command index.") + + # For non-latest, use local command index and fallback logic. + index = None + if self._is_index_valid(): + index = self.INDEX.get(self._COMMAND_INDEX) or {} + else: + # `az version` should stay fast even when extensions are installed. + force_packaged_for_version = bool(top_command == 'version' and self.cloud_profile == 'latest') + index = self._get_packaged_command_index(ignore_extensions=force_packaged_for_version) + if index is None: + logger.debug("Command index version or cloud profile is invalid or doesn't match the current command.") + self.invalidate() + return None + + return self._lookup_command_in_index(index, normalized_args) + + def _lookup_command_in_index(self, index, args, force_load_all_extensions=False): + """Lookup command modules/extensions from a resolved index mapping.""" # Make sure the top-level command is provided, like `az version`. # Skip command index for `az` or `az --help`. if not args or args[0].startswith('-'): # For top-level completion (az [tab]) if not args and self.cli_ctx.data.get('completer_active'): - return self._get_top_level_completion_commands() + return self._get_top_level_completion_commands(index=index) return None # Get the top-level command, like `network` in `network vnet create -h` - # Normalize top-level command for index lookup so mixed-case commands hit key top_command = args[0].lower() index = self.INDEX[self._COMMAND_INDEX] + # Check the command index for (command: [module]) mapping, like # "network": ["azure.cli.command_modules.natgateway", "azure.cli.command_modules.network", "azext_firewall"] index_modules_extensions = index.get(top_command) @@ -822,8 +1077,16 @@ def get(self, args): index_extensions.append(m) else: logger.warning("Unrecognized module: %s", m) + + if force_load_all_extensions: + logger.debug("Extension index is unavailable. Loading all installed extensions for safety.") + index_extensions = None return index_builtin_modules, index_extensions + if force_load_all_extensions and not self.cli_ctx.data['completer_active']: + logger.debug("Top-level command '%s' not found in blended index. Loading all extensions.", top_command) + return [], None + return None def get_help_index(self): @@ -831,10 +1094,28 @@ def get_help_index(self): :return: Dictionary mapping top-level commands to their short summaries, or None if not available """ + if self.cloud_profile == 'latest': + # Prefer local cache if available and valid, as it may include extension-specific help entries. + if self._is_index_valid(): + help_index = self.HELP_INDEX.get(self._HELP_INDEX, {}) + if not help_index: + help_index = self._migrate_legacy_help_index() or {} + if help_index: + logger.debug("Using cached local help index with %d entries", len(help_index)) + return help_index + + packaged_help_index = self._load_packaged_help_index() + if packaged_help_index: + logger.debug("Using packaged help index with %d entries", len(packaged_help_index)) + return packaged_help_index + return None + if not self._is_index_valid(): return None - help_index = self.INDEX.get(self._HELP_INDEX, {}) + help_index = self.HELP_INDEX.get(self._HELP_INDEX, {}) + if not help_index: + help_index = self._migrate_legacy_help_index() or {} if help_index: logger.debug("Using cached help index with %d entries", len(help_index)) return help_index @@ -846,7 +1127,10 @@ def set_help_index(self, help_data): :param help_data: Help index data structure containing groups and commands """ - self.INDEX[self._HELP_INDEX] = help_data + self.HELP_INDEX[self._HELP_INDEX] = help_data + # Clear legacy key if it exists in commandIndex.json. + if self.INDEX.get(self._HELP_INDEX): + self.INDEX[self._HELP_INDEX] = {} def update(self, command_table): """Update the command index according to the given command table. @@ -870,6 +1154,20 @@ def update(self, command_table): elapsed_time = timeit.default_timer() - start_time self.INDEX[self._COMMAND_INDEX] = index + + # Maintain extension-only overlay for latest profile so packaged core can be blended. + if self.cloud_profile == 'latest': + extension_index = defaultdict(list) + for command_name, command in command_table.items(): + top_command = command_name.split()[0] + module_name = command.loader.__module__ + if module_name.startswith('azext_') and module_name not in extension_index[top_command]: + extension_index[top_command].append(module_name) + + self.EXTENSION_INDEX[self._COMMAND_INDEX_VERSION] = __version__ + self.EXTENSION_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = self.cloud_profile + self.EXTENSION_INDEX[self._COMMAND_INDEX] = extension_index + logger.debug("Updated command index in %.3f seconds.", elapsed_time) def invalidate(self): @@ -886,7 +1184,13 @@ 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] = {} + self.EXTENSION_INDEX[self._COMMAND_INDEX_VERSION] = "" + self.EXTENSION_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" + self.EXTENSION_INDEX[self._COMMAND_INDEX] = {} + self.HELP_INDEX[self._HELP_INDEX] = {} + # Clear legacy key if it exists in commandIndex.json. + if self.INDEX.get(self._HELP_INDEX): + self.INDEX[self._HELP_INDEX] = {} logger.debug("Command index has been invalidated.") diff --git a/src/azure-cli-core/azure/cli/core/_session.py b/src/azure-cli-core/azure/cli/core/_session.py index 463174218fc..356eddab690 100644 --- a/src/azure-cli-core/azure/cli/core/_session.py +++ b/src/azure-cli-core/azure/cli/core/_session.py @@ -97,6 +97,12 @@ def __len__(self): # INDEX contains {top-level command: [command_modules and extensions]} mapping index INDEX = Session() +# EXTENSION_INDEX contains top-level command mappings for installed extensions +EXTENSION_INDEX = Session() + +# HELP_INDEX contains cached help summaries for top-level help display +HELP_INDEX = Session() + # VERSIONS provides local versions and pypi versions. # DO NOT USE it to get the current version of azure-cli, # it could be lagged behind and can be used to check whether diff --git a/src/azure-cli-core/azure/cli/core/commandIndex.latest.json b/src/azure-cli-core/azure/cli/core/commandIndex.latest.json new file mode 100644 index 00000000000..1709a1a0292 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/commandIndex.latest.json @@ -0,0 +1,327 @@ +{ + "version": "2.84.0", + "cloudProfile": "latest", + "commandIndex": { + "advisor": [ + "azure.cli.command_modules.advisor" + ], + "ams": [ + "azure.cli.command_modules.ams" + ], + "appconfig": [ + "azure.cli.command_modules.appconfig" + ], + "apim": [ + "azure.cli.command_modules.apim" + ], + "acr": [ + "azure.cli.command_modules.acr" + ], + "aro": [ + "azure.cli.command_modules.aro" + ], + "backup": [ + "azure.cli.command_modules.backup" + ], + "batchai": [ + "azure.cli.command_modules.batchai" + ], + "batch": [ + "azure.cli.command_modules.batch" + ], + "appservice": [ + "azure.cli.command_modules.appservice" + ], + "webapp": [ + "azure.cli.command_modules.appservice", + "azure.cli.command_modules.serviceconnector" + ], + "functionapp": [ + "azure.cli.command_modules.appservice", + "azure.cli.command_modules.serviceconnector" + ], + "staticwebapp": [ + "azure.cli.command_modules.appservice" + ], + "logicapp": [ + "azure.cli.command_modules.appservice" + ], + "bot": [ + "azure.cli.command_modules.botservice" + ], + "cloud": [ + "azure.cli.command_modules.cloud" + ], + "cognitiveservices": [ + "azure.cli.command_modules.cognitiveservices" + ], + "billing": [ + "azure.cli.command_modules.billing" + ], + "compute-recommender": [ + "azure.cli.command_modules.compute_recommender" + ], + "config": [ + "azure.cli.command_modules.config" + ], + "compute-fleet": [ + "azure.cli.command_modules.computefleet" + ], + "configure": [ + "azure.cli.command_modules.configure" + ], + "cache": [ + "azure.cli.command_modules.configure" + ], + "container": [ + "azure.cli.command_modules.container" + ], + "consumption": [ + "azure.cli.command_modules.consumption" + ], + "aks": [ + "azure.cli.command_modules.acs", + "azure.cli.command_modules.serviceconnector" + ], + "cosmosdb": [ + "azure.cli.command_modules.cosmosdb" + ], + "managed-cassandra": [ + "azure.cli.command_modules.cosmosdb" + ], + "dls": [ + "azure.cli.command_modules.dls" + ], + "databoxedge": [ + "azure.cli.command_modules.databoxedge" + ], + "dms": [ + "azure.cli.command_modules.dms" + ], + "eventgrid": [ + "azure.cli.command_modules.eventgrid" + ], + "extension": [ + "azure.cli.command_modules.extension" + ], + "feedback": [ + "azure.cli.command_modules.feedback" + ], + "survey": [ + "azure.cli.command_modules.feedback" + ], + "find": [ + "azure.cli.command_modules.find" + ], + "hdinsight": [ + "azure.cli.command_modules.hdinsight" + ], + "eventhubs": [ + "azure.cli.command_modules.eventhubs" + ], + "interactive": [ + "azure.cli.command_modules.interactive" + ], + "identity": [ + "azure.cli.command_modules.identity" + ], + "keyvault": [ + "azure.cli.command_modules.keyvault" + ], + "lab": [ + "azure.cli.command_modules.lab" + ], + "iot": [ + "azure.cli.command_modules.iot" + ], + "containerapp": [ + "azure.cli.command_modules.containerapp", + "azure.cli.command_modules.serviceconnector" + ], + "maps": [ + "azure.cli.command_modules.maps" + ], + "managedservices": [ + "azure.cli.command_modules.managedservices" + ], + "term": [ + "azure.cli.command_modules.marketplaceordering" + ], + "afd": [ + "azure.cli.command_modules.cdn" + ], + "cdn": [ + "azure.cli.command_modules.cdn" + ], + "netappfiles": [ + "azure.cli.command_modules.netappfiles" + ], + "mysql": [ + "azure.cli.command_modules.mysql", + "azure.cli.command_modules.rdbms" + ], + "policy": [ + "azure.cli.command_modules.policyinsights", + "azure.cli.command_modules.resource" + ], + "network": [ + "azure.cli.command_modules.privatedns", + "azure.cli.command_modules.network" + ], + "login": [ + "azure.cli.command_modules.profile" + ], + "logout": [ + "azure.cli.command_modules.profile" + ], + "self-test": [ + "azure.cli.command_modules.profile" + ], + "account": [ + "azure.cli.command_modules.profile", + "azure.cli.command_modules.resource" + ], + "postgres": [ + "azure.cli.command_modules.postgresql" + ], + "redis": [ + "azure.cli.command_modules.redis" + ], + "mariadb": [ + "azure.cli.command_modules.rdbms" + ], + "relay": [ + "azure.cli.command_modules.relay" + ], + "role": [ + "azure.cli.command_modules.role" + ], + "ad": [ + "azure.cli.command_modules.role" + ], + "search": [ + "azure.cli.command_modules.search" + ], + "security": [ + "azure.cli.command_modules.security" + ], + "data-boundary": [ + "azure.cli.command_modules.resource" + ], + "group": [ + "azure.cli.command_modules.resource" + ], + "resource": [ + "azure.cli.command_modules.resource" + ], + "provider": [ + "azure.cli.command_modules.resource" + ], + "feature": [ + "azure.cli.command_modules.resource" + ], + "tag": [ + "azure.cli.command_modules.resource" + ], + "deployment": [ + "azure.cli.command_modules.resource" + ], + "deployment-scripts": [ + "azure.cli.command_modules.resource" + ], + "ts": [ + "azure.cli.command_modules.resource" + ], + "stack": [ + "azure.cli.command_modules.resource" + ], + "lock": [ + "azure.cli.command_modules.resource" + ], + "managedapp": [ + "azure.cli.command_modules.resource" + ], + "bicep": [ + "azure.cli.command_modules.resource" + ], + "resourcemanagement": [ + "azure.cli.command_modules.resource" + ], + "private-link": [ + "azure.cli.command_modules.resource" + ], + "servicebus": [ + "azure.cli.command_modules.servicebus" + ], + "sf": [ + "azure.cli.command_modules.servicefabric" + ], + "signalr": [ + "azure.cli.command_modules.signalr" + ], + "sql": [ + "azure.cli.command_modules.sql", + "azure.cli.command_modules.sqlvm" + ], + "storage": [ + "azure.cli.command_modules.storage" + ], + "synapse": [ + "azure.cli.command_modules.synapse" + ], + "rest": [ + "azure.cli.command_modules.util" + ], + "version": [ + "azure.cli.command_modules.util" + ], + "upgrade": [ + "azure.cli.command_modules.util" + ], + "demo": [ + "azure.cli.command_modules.util" + ], + "connection": [ + "azure.cli.command_modules.serviceconnector" + ], + "capacity": [ + "azure.cli.command_modules.vm" + ], + "disk": [ + "azure.cli.command_modules.vm" + ], + "disk-access": [ + "azure.cli.command_modules.vm" + ], + "disk-encryption-set": [ + "azure.cli.command_modules.vm" + ], + "image": [ + "azure.cli.command_modules.vm" + ], + "ppg": [ + "azure.cli.command_modules.vm" + ], + "restore-point": [ + "azure.cli.command_modules.vm" + ], + "sig": [ + "azure.cli.command_modules.vm" + ], + "snapshot": [ + "azure.cli.command_modules.vm" + ], + "vm": [ + "azure.cli.command_modules.vm" + ], + "vmss": [ + "azure.cli.command_modules.vm" + ], + "sshkey": [ + "azure.cli.command_modules.vm" + ], + "monitor": [ + "azure.cli.command_modules.monitor" + ] + } +} diff --git a/src/azure-cli-core/azure/cli/core/helpIndex.latest.json b/src/azure-cli-core/azure/cli/core/helpIndex.latest.json new file mode 100644 index 00000000000..ca9c215d7a8 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/helpIndex.latest.json @@ -0,0 +1,414 @@ +{ + "version": "2.84.0", + "cloudProfile": "latest", + "helpIndex": { + "groups": { + "aks": { + "summary": "Azure Kubernetes Service.", + "tags": "" + }, + "network": { + "summary": "Manage Azure Network resources.", + "tags": "" + }, + "monitor": { + "summary": "Manage the Azure Monitor Service.", + "tags": "" + }, + "containerapp": { + "summary": "Manage Azure Container Apps.", + "tags": "" + }, + "advisor": { + "summary": "Manage Azure Advisor.", + "tags": "" + }, + "ams": { + "summary": "Manage Azure Media Services resources.", + "tags": "" + }, + "appconfig": { + "summary": "Manage App Configurations.", + "tags": "" + }, + "acr": { + "summary": "Manage private registries with Azure Container Registries.", + "tags": "" + }, + "apim": { + "summary": "Manage Azure API Management services.", + "tags": "" + }, + "backup": { + "summary": "Manage Azure Backups.", + "tags": "" + }, + "aro": { + "summary": "Manage Azure Red Hat OpenShift clusters.", + "tags": "" + }, + "batch": { + "summary": "Manage Azure Batch.", + "tags": "" + }, + "bot": { + "summary": "Manage Microsoft Azure Bot Service.", + "tags": "" + }, + "appservice": { + "summary": "Manage Appservice.", + "tags": "" + }, + "webapp": { + "summary": "Manage web apps.", + "tags": "" + }, + "functionapp": { + "summary": "Manage function apps. To install the Azure Functions Core tools see https://github.com/Azure/azure-functions-core-tools.", + "tags": "" + }, + "staticwebapp": { + "summary": "Manage static apps.", + "tags": "" + }, + "logicapp": { + "summary": "Manage logic apps.", + "tags": "" + }, + "cloud": { + "summary": "Manage registered Azure clouds.", + "tags": "" + }, + "cognitiveservices": { + "summary": "Manage Azure Cognitive Services accounts.", + "tags": "" + }, + "billing": { + "summary": "Manage Azure Billing.", + "tags": "" + }, + "compute-recommender": { + "summary": "Manage sku/zone/region recommender info for compute resources.", + "tags": "" + }, + "config": { + "summary": "Manage Azure CLI configuration.", + "tags": "[Experimental]" + }, + "compute-fleet": { + "summary": "Manage for Azure Compute Fleet.", + "tags": "[Preview]" + }, + "cache": { + "summary": "Commands to manage CLI objects cached using the `--defer` argument.", + "tags": "" + }, + "container": { + "summary": "Manage Azure Container Instances.", + "tags": "" + }, + "cosmosdb": { + "summary": "Manage Azure Cosmos DB database accounts.", + "tags": "" + }, + "managed-cassandra": { + "summary": "Azure Managed Cassandra.", + "tags": "" + }, + "dls": { + "summary": "Manage Data Lake Store accounts and filesystems.", + "tags": "[Preview]" + }, + "databoxedge": { + "summary": "Manage device with databoxedge.", + "tags": "" + }, + "consumption": { + "summary": "Manage consumption of Azure resources.", + "tags": "[Preview]" + }, + "dms": { + "summary": "Manage Azure Data Migration Service (classic) instances.", + "tags": "" + }, + "eventgrid": { + "summary": "Manage Azure Event Grid topics, domains, domain topics, system topics partner topics, event subscriptions, system topic event subscriptions and partner topic event subscriptions.", + "tags": "" + }, + "extension": { + "summary": "Manage and update CLI extensions.", + "tags": "" + }, + "hdinsight": { + "summary": "Manage HDInsight resources.", + "tags": "" + }, + "identity": { + "summary": "Manage Managed Identity.", + "tags": "" + }, + "eventhubs": { + "summary": "Eventhubs.", + "tags": "" + }, + "keyvault": { + "summary": "Manage KeyVault keys, secrets, and certificates.", + "tags": "" + }, + "managedservices": { + "summary": "Manage the registration assignments and definitions in Azure.", + "tags": "" + }, + "maps": { + "summary": "Manage Azure Maps.", + "tags": "" + }, + "term": { + "summary": "Manage marketplace agreement with marketplaceordering.", + "tags": "[Experimental]" + }, + "lab": { + "summary": "Manage azure devtest labs.", + "tags": "[Preview]" + }, + "afd": { + "summary": "Manage Azure Front Door Standard/Premium.", + "tags": "" + }, + "cdn": { + "summary": "Manage Azure Content Delivery Networks (CDNs).", + "tags": "" + }, + "iot": { + "summary": "Manage Internet of Things (IoT) assets.", + "tags": "" + }, + "netappfiles": { + "summary": "Manage Azure NetApp Files (ANF) Resources.", + "tags": "" + }, + "mysql": { + "summary": "Manage Azure Database for MySQL servers.", + "tags": "" + }, + "policy": { + "summary": "Manage resources defined and used by the Azure Policy service.", + "tags": "" + }, + "account": { + "summary": "Manage Azure subscription information.", + "tags": "" + }, + "postgres": { + "summary": "Manage Azure Database for PostgreSQL.", + "tags": "" + }, + "redis": { + "summary": "Manage dedicated Redis caches for your Azure applications.", + "tags": "" + }, + "relay": { + "summary": "Manage Azure Relay Service namespaces, WCF relays, hybrid connections, and rules.", + "tags": "" + }, + "mariadb": { + "summary": "Manage Azure Database for MariaDB servers.", + "tags": "" + }, + "role": { + "summary": "Manage Azure role-based access control (Azure RBAC).", + "tags": "" + }, + "ad": { + "summary": "Manage Microsoft Entra ID (formerly known as Azure Active Directory, Azure AD, AAD) entities needed for Azure role-based access control (Azure RBAC) through Microsoft Graph API.", + "tags": "" + }, + "search": { + "summary": "Manage Search.", + "tags": "" + }, + "security": { + "summary": "Manage your security posture with Microsoft Defender for Cloud.", + "tags": "" + }, + "servicebus": { + "summary": "Servicebus.", + "tags": "" + }, + "data-boundary": { + "summary": "Data boundary operations.", + "tags": "" + }, + "group": { + "summary": "Manage resource groups and template deployments.", + "tags": "" + }, + "resource": { + "summary": "Manage Azure resources.", + "tags": "" + }, + "provider": { + "summary": "Manage resource providers.", + "tags": "" + }, + "feature": { + "summary": "Manage resource provider features.", + "tags": "" + }, + "tag": { + "summary": "Tag Management on a resource.", + "tags": "" + }, + "deployment": { + "summary": "Manage Azure Resource Manager template deployment at subscription scope.", + "tags": "" + }, + "deployment-scripts": { + "summary": "Manage deployment scripts at subscription or resource group scope.", + "tags": "" + }, + "ts": { + "summary": "Manage template specs at subscription or resource group scope.", + "tags": "" + }, + "stack": { + "summary": "A deployment stack is a native Azure resource type that enables you to perform operations on a resource collection as an atomic unit.", + "tags": "" + }, + "lock": { + "summary": "Manage Azure locks.", + "tags": "" + }, + "managedapp": { + "summary": "Manage template solutions provided and maintained by Independent Software Vendors (ISVs).", + "tags": "" + }, + "bicep": { + "summary": "Bicep CLI command group.", + "tags": "" + }, + "resourcemanagement": { + "summary": "Resourcemanagement CLI command group.", + "tags": "" + }, + "private-link": { + "summary": "Private-link association CLI command group.", + "tags": "" + }, + "sf": { + "summary": "Manage and administer Azure Service Fabric clusters.", + "tags": "" + }, + "signalr": { + "summary": "Manage Azure SignalR Service.", + "tags": "" + }, + "sql": { + "summary": "Manage Azure SQL Databases and Data Warehouses.", + "tags": "" + }, + "storage": { + "summary": "Manage Azure Cloud Storage resources.", + "tags": "" + }, + "synapse": { + "summary": "Manage and operate Synapse Workspace, Spark Pool, SQL Pool.", + "tags": "" + }, + "connection": { + "summary": "Commands to manage Service Connector local connections which allow local environment to connect Azure Resource. If you want to manage connection for compute service, please run 'az webapp/containerapp/spring connection'.", + "tags": "" + }, + "capacity": { + "summary": "Manage capacity.", + "tags": "" + }, + "disk": { + "summary": "Manage Azure Managed Disks.", + "tags": "" + }, + "disk-access": { + "summary": "Manage disk access resources.", + "tags": "" + }, + "disk-encryption-set": { + "summary": "Disk Encryption Set resource.", + "tags": "" + }, + "image": { + "summary": "Manage custom virtual machine images.", + "tags": "" + }, + "ppg": { + "summary": "Manage Proximity Placement Groups.", + "tags": "" + }, + "restore-point": { + "summary": "Manage restore point with res.", + "tags": "" + }, + "sig": { + "summary": "Manage shared image gallery.", + "tags": "" + }, + "snapshot": { + "summary": "Manage point-in-time copies of managed disks, native blobs, or other snapshots.", + "tags": "" + }, + "vm": { + "summary": "Manage Linux or Windows virtual machines.", + "tags": "" + }, + "vmss": { + "summary": "Manage groupings of virtual machines in an Azure Virtual Machine Scale Set (VMSS).", + "tags": "" + }, + "sshkey": { + "summary": "Manage ssh public key with vm.", + "tags": "" + } + }, + "commands": { + "configure": { + "summary": "Manage Azure CLI configuration. This command is interactive.", + "tags": "" + }, + "feedback": { + "summary": "Send feedback to the Azure CLI Team.", + "tags": "" + }, + "survey": { + "summary": "Take Azure CLI survey.", + "tags": "" + }, + "find": { + "summary": "I'm an AI robot, my advice is based on our Azure documentation as well as the usage patterns of Azure CLI and Azure ARM users. Using me improves Azure products and documentation.", + "tags": "" + }, + "interactive": { + "summary": "Start interactive mode. Installs the Interactive extension if not installed already.", + "tags": "[Preview]" + }, + "login": { + "summary": "Log in to Azure.", + "tags": "" + }, + "logout": { + "summary": "Log out to remove access to Azure subscriptions.", + "tags": "" + }, + "rest": { + "summary": "Invoke a custom request.", + "tags": "" + }, + "version": { + "summary": "Show the versions of Azure CLI modules and extensions in JSON format by default or format configured by --output.", + "tags": "" + }, + "upgrade": { + "summary": "Upgrade Azure CLI and extensions.", + "tags": "[Preview]" + } + } + } +} diff --git a/src/azure-cli-core/azure/cli/core/mock.py b/src/azure-cli-core/azure/cli/core/mock.py index 4f125a1b7ae..1b2cf8515ad 100644 --- a/src/azure-cli-core/azure/cli/core/mock.py +++ b/src/azure-cli-core/azure/cli/core/mock.py @@ -33,7 +33,7 @@ def __init__(self, commands_loader_cls=None, random_config_dir=False, **kwargs): self.env_patch.start() # Always copy command index to avoid initializing it again - files_to_copy = ['commandIndex.json'] + files_to_copy = ['commandIndex.json', 'extensionIndex.json', 'helpIndex.json'] # In recording mode, copy login credentials from global config dir to the dummy config dir if os.getenv(ENV_VAR_TEST_LIVE, '').lower() == 'true': files_to_copy.extend([ diff --git a/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py b/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py index 0e3ec03b70a..b2b71be4a95 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py @@ -165,6 +165,9 @@ def _mock_get_extensions(): MockExtension(name=__name__ + '.Ext2CommandsLoader', preview=False, experimental=False, path=None, get_metadata=lambda: {}), MockExtension(name=__name__ + '.ExtAlwaysLoadedCommandsLoader', preview=False, experimental=False, path=None, get_metadata=lambda: {})] + def _mock_no_extensions(): + return [] + def _mock_load_command_loader(loader, args, name, prefix): class TestCommandsLoader(AzCommandsLoader): @@ -186,6 +189,13 @@ def load_command_table(self, args): self.__module__ = "azure.cli.command_modules.extra" return self.command_table + class UtilCommandsLoader(AzCommandsLoader): + def load_command_table(self, args): + with self.command_group('version', operations_tmpl='{}#TestCommandRegistration.{{}}'.format(__name__)) as g: + g.command('', 'sample_vm_get') + self.__module__ = "azure.cli.command_modules.util" + return self.command_table + # Extend existing group by adding a new command class ExtCommandsLoader(AzCommandsLoader): @@ -214,7 +224,7 @@ def load_command_table(self, args): return self.command_table if prefix == 'azure.cli.command_modules.': - command_loaders = {'hello': TestCommandsLoader, 'extra': Test2CommandsLoader} + command_loaders = {'hello': TestCommandsLoader, 'extra': Test2CommandsLoader, 'util': UtilCommandsLoader} else: command_loaders = {'azext_hello1': ExtCommandsLoader, 'azext_hello2': Ext2CommandsLoader, @@ -429,6 +439,240 @@ def update_and_check_index(): del INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] del INDEX[CommandIndex._COMMAND_INDEX] + @mock.patch('importlib.import_module', _mock_import_lib) + @mock.patch('pkgutil.iter_modules', _mock_iter_modules) + @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) + @mock.patch('azure.cli.core.extension.get_extensions', _mock_no_extensions) + def test_command_index_uses_packaged_latest_without_seeding(self): + from azure.cli.core._session import INDEX + from azure.cli.core import CommandIndex, __version__ + + cli = DummyCli() + loader = cli.commands_loader + + # Simulate no local index metadata. This is the no-seeding path. + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + INDEX[CommandIndex._COMMAND_INDEX] = {} + + packaged_index = { + CommandIndex._COMMAND_INDEX_VERSION: __version__, + CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: cli.cloud.profile, + CommandIndex._COMMAND_INDEX: { + 'hello': ['azure.cli.command_modules.hello'], + 'extra': ['azure.cli.command_modules.extra'] + } + } + + with mock.patch.object(CommandIndex, '_load_packaged_command_index', return_value=packaged_index): + cmd_tbl = loader.load_command_table(["hello", "mod-only"]) + + # Uses packaged index directly and doesn't require local index seeding. + self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden']) + self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) + + @mock.patch('importlib.import_module', _mock_import_lib) + @mock.patch('pkgutil.iter_modules', _mock_iter_modules) + @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) + @mock.patch('azure.cli.core.extension.get_extensions', _mock_no_extensions) + def test_command_index_handles_leading_debug_flag(self): + from azure.cli.core._session import INDEX + from azure.cli.core import CommandIndex, __version__ + + cli = DummyCli() + loader = cli.commands_loader + + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + INDEX[CommandIndex._COMMAND_INDEX] = {} + + packaged_index = { + CommandIndex._COMMAND_INDEX_VERSION: __version__, + CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: cli.cloud.profile, + CommandIndex._COMMAND_INDEX: { + 'hello': ['azure.cli.command_modules.hello'] + } + } + + with mock.patch.object(CommandIndex, '_load_packaged_command_index', return_value=packaged_index): + cmd_tbl = loader.load_command_table(["--debug", "hello", "mod-only"]) + + self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden']) + self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) + + @mock.patch('importlib.import_module', _mock_import_lib) + @mock.patch('pkgutil.iter_modules', _mock_iter_modules) + @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) + @mock.patch('azure.cli.core.extension.get_extensions', _mock_no_extensions) + def test_command_index_handles_leading_output_option(self): + from azure.cli.core._session import INDEX + from azure.cli.core import CommandIndex, __version__ + + cli = DummyCli() + loader = cli.commands_loader + + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + INDEX[CommandIndex._COMMAND_INDEX] = {} + + packaged_index = { + CommandIndex._COMMAND_INDEX_VERSION: __version__, + CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: cli.cloud.profile, + CommandIndex._COMMAND_INDEX: { + 'hello': ['azure.cli.command_modules.hello'] + } + } + + with mock.patch.object(CommandIndex, '_load_packaged_command_index', return_value=packaged_index): + cmd_tbl = loader.load_command_table(["-o", "json", "hello", "mod-only"]) + + self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden']) + self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) + + @mock.patch('importlib.import_module', _mock_import_lib) + @mock.patch('pkgutil.iter_modules', _mock_iter_modules) + @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) + @mock.patch('azure.cli.core.extension.get_extension_modname', _mock_get_extension_modname) + @mock.patch('azure.cli.core.extension.get_extensions', _mock_get_extensions) + def test_command_index_loads_all_extensions_when_overlay_missing(self): + from azure.cli.core._session import INDEX + from azure.cli.core import CommandIndex, __version__ + + cli = DummyCli() + loader = cli.commands_loader + + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + INDEX[CommandIndex._COMMAND_INDEX] = {} + + packaged_index = { + CommandIndex._COMMAND_INDEX_VERSION: __version__, + CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: cli.cloud.profile, + CommandIndex._COMMAND_INDEX: { + 'hello': ['azure.cli.command_modules.hello'], + 'extra': ['azure.cli.command_modules.extra'] + } + } + + with mock.patch.object(CommandIndex, '_load_packaged_command_index', return_value=packaged_index): + cmd_tbl = loader.load_command_table(["hello", "mod-only"]) + + # Missing overlay triggers loading all extensions, but avoids full module rebuild. + self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden', 'hello ext-only']) + self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) + + @mock.patch('importlib.import_module', _mock_import_lib) + @mock.patch('pkgutil.iter_modules', _mock_iter_modules) + @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) + @mock.patch('azure.cli.core.extension.get_extension_modname', _mock_get_extension_modname) + @mock.patch('azure.cli.core.extension.get_extensions', _mock_get_extensions) + def test_command_index_blends_packaged_with_extension_overlay(self): + from azure.cli.core._session import INDEX, EXTENSION_INDEX + from azure.cli.core import CommandIndex, __version__ + + cli = DummyCli() + loader = cli.commands_loader + + # Local command index is empty; packaged core index + extensionIndex overlay should be blended. + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + INDEX[CommandIndex._COMMAND_INDEX] = {} + + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_VERSION] = __version__ + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = cli.cloud.profile + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX] = { + 'hello': ['azext_hello1', 'azext_hello2'] + } + + packaged_index = { + CommandIndex._COMMAND_INDEX_VERSION: __version__, + CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: cli.cloud.profile, + CommandIndex._COMMAND_INDEX: { + 'hello': ['azure.cli.command_modules.hello'], + 'extra': ['azure.cli.command_modules.extra'] + } + } + + with mock.patch.object(CommandIndex, '_load_packaged_command_index', return_value=packaged_index): + cmd_tbl = loader.load_command_table(["hello", "mod-only"]) + + # Extension commands are loaded through the overlay without rebuilding all modules. + self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden', 'hello ext-only']) + self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) + + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX] = {} + + def test_packaged_command_index_file_schema(self): + from azure.cli.core import CommandIndex, __version__ + + cli = DummyCli() + command_index = CommandIndex(cli) + packaged_index = command_index._load_packaged_command_index() # pylint: disable=protected-access + + self.assertIsNotNone(packaged_index) + self.assertEqual(packaged_index.get(CommandIndex._COMMAND_INDEX_VERSION), __version__) + self.assertEqual(packaged_index.get(CommandIndex._COMMAND_INDEX_CLOUD_PROFILE), 'latest') + self.assertTrue(packaged_index.get(CommandIndex._COMMAND_INDEX)) + + @mock.patch('importlib.import_module', _mock_import_lib) + @mock.patch('pkgutil.iter_modules', _mock_iter_modules) + @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) + @mock.patch('azure.cli.core.extension.get_extension_modname', _mock_get_extension_modname) + @mock.patch('azure.cli.core.extension.get_extensions', _mock_get_extensions) + def test_command_index_uses_packaged_for_version_with_extensions(self): + from azure.cli.core._session import INDEX + from azure.cli.core import CommandIndex, __version__ + + cli = DummyCli() + loader = cli.commands_loader + + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + INDEX[CommandIndex._COMMAND_INDEX] = {} + + packaged_index = { + CommandIndex._COMMAND_INDEX_VERSION: __version__, + CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: cli.cloud.profile, + CommandIndex._COMMAND_INDEX: { + 'version': ['azure.cli.command_modules.util'] + } + } + + with mock.patch.object(CommandIndex, '_load_packaged_command_index', return_value=packaged_index): + cmd_tbl = loader.load_command_table(["version"]) + + self.assertIn('version', cmd_tbl) + # No full rebuild should happen for this fast path. + self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) + + @mock.patch('importlib.import_module', _mock_import_lib) + @mock.patch('pkgutil.iter_modules', _mock_iter_modules) + @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) + @mock.patch('azure.cli.core.extension.get_extension_modname', _mock_get_extension_modname) + @mock.patch('azure.cli.core.extension.get_extensions', _mock_get_extensions) + def test_command_index_non_latest_uses_local_mechanism(self): + from azure.cli.core._session import INDEX + from azure.cli.core import CommandIndex, __version__ + + cli = DummyCli() + loader = cli.commands_loader + + with mock.patch.object(cli.cloud, "profile", "2019-03-01-hybrid"): + # Valid local index for non-latest profile should be used directly. + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = __version__ + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "2019-03-01-hybrid" + INDEX[CommandIndex._COMMAND_INDEX] = { + 'hello': ['azure.cli.command_modules.hello'] + } + + with mock.patch.object(CommandIndex, '_load_packaged_command_index', + side_effect=AssertionError('Packaged index should not be used for non-latest')): + cmd_tbl = loader.load_command_table(["hello", "mod-only"]) + + self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden']) + @mock.patch('importlib.import_module', _mock_import_lib) @mock.patch('pkgutil.iter_modules', _mock_iter_modules) @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) 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 15528e71440..5afde28cb32 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 @@ -164,7 +164,9 @@ def tearDown(self): 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 + from azure.cli.core._session import HELP_INDEX, INDEX + if 'helpIndex' in HELP_INDEX: + del HELP_INDEX['helpIndex'] if 'helpIndex' in INDEX: del INDEX['helpIndex'] @@ -543,7 +545,7 @@ def test_help_cache_extraction(self): 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 + from azure.cli.core._session import HELP_INDEX test_help_data = { 'groups': { @@ -557,7 +559,7 @@ def test_help_cache_storage_and_retrieval(self): command_index = CommandIndex(self.test_cli) command_index.set_help_index(test_help_data) - retrieved = INDEX.get('helpIndex') + retrieved = HELP_INDEX.get('helpIndex') self.assertIsNotNone(retrieved) self.assertIn('groups', retrieved) @@ -568,18 +570,75 @@ def test_help_cache_storage_and_retrieval(self): 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 + from azure.cli.core._session import HELP_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) + self.assertIn('helpIndex', HELP_INDEX) command_index.invalidate() + self.assertEqual(HELP_INDEX.get('helpIndex'), {}) + + def test_help_cache_legacy_migration(self): + """Test legacy helpIndex migration from commandIndex.json to helpIndex.json.""" + from azure.cli.core import CommandIndex, __version__ + from azure.cli.core._session import HELP_INDEX, INDEX + + test_help_data = { + 'groups': {'legacy-group': {'summary': 'Legacy summary', 'tags': ''}}, + 'commands': {'legacy-cmd': {'summary': 'Legacy command', 'tags': ''}} + } + + command_index = CommandIndex(self.test_cli) + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = __version__ + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = self.test_cli.cloud.profile + INDEX['helpIndex'] = test_help_data + + migrated = command_index.get_help_index() + + self.assertEqual(migrated, test_help_data) + self.assertEqual(HELP_INDEX.get('helpIndex'), test_help_data) self.assertEqual(INDEX.get('helpIndex'), {}) + def test_packaged_help_index_file_schema(self): + """Test packaged helpIndex.latest.json schema and metadata.""" + from azure.cli.core import CommandIndex, __version__ + + command_index = CommandIndex(self.test_cli) + packaged_help_index = command_index._load_packaged_help_index() # pylint: disable=protected-access + + self.assertIsNotNone(packaged_help_index) + self.assertIsInstance(packaged_help_index, dict) + self.assertIn('groups', packaged_help_index) + self.assertIn('commands', packaged_help_index) + self.assertEqual(command_index.version, __version__) + + def test_help_index_uses_packaged_latest_without_local_index(self): + """Test latest profile uses packaged help index when local command/help index is invalid.""" + from azure.cli.core import CommandIndex + from azure.cli.core._session import HELP_INDEX, INDEX + + command_index = CommandIndex(self.test_cli) + + # Simulate missing local command/help cache metadata. + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + INDEX[CommandIndex._COMMAND_INDEX] = {} + HELP_INDEX[CommandIndex._HELP_INDEX] = {} + + packaged_help_data = { + 'groups': {'vm': {'summary': 'Manage VMs.', 'tags': ''}}, + 'commands': {'version': {'summary': 'Show version.', 'tags': ''}} + } + + with mock.patch.object(CommandIndex, '_load_packaged_help_index', return_value=packaged_help_data): + help_index = command_index.get_help_index() + + self.assertEqual(help_index, packaged_help_data) + def test_show_cached_help_output(self): """Test that cached help is displayed correctly.""" from azure.cli.core._help import AzCliHelp diff --git a/src/azure-cli-core/setup.py b/src/azure-cli-core/setup.py index 90bec1c5f19..2c037a399bb 100644 --- a/src/azure-cli-core/setup.py +++ b/src/azure-cli-core/setup.py @@ -84,5 +84,5 @@ packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests", "azure", "azure.cli"]), install_requires=DEPENDENCIES, python_requires='>=3.10.0', - package_data={'azure.cli.core': ['auth/landing_pages/*.html']} + package_data={'azure.cli.core': ['auth/landing_pages/*.html', 'commandIndex.latest.json', 'helpIndex.latest.json']} ) From 794b5e508b059112c32664ebb3ac9fb4d31173f4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 12 Mar 2026 09:35:04 +1100 Subject: [PATCH 02/26] feature: adjust helpindex logic to draw from overlay for extensions --- src/azure-cli-core/azure/cli/core/__init__.py | 103 +++++++++++++++--- src/azure-cli-core/azure/cli/core/_session.py | 3 + src/azure-cli-core/azure/cli/core/mock.py | 2 +- .../azure/cli/core/tests/test_help.py | 91 +++++++++++++--- 4 files changed, 170 insertions(+), 29 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 997249dda2b..05b8f5c6352 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -73,7 +73,7 @@ def __init__(self, **kwargs): register_ids_argument, register_global_subscription_argument, register_global_policy_argument) from azure.cli.core.cloud import get_active_cloud from azure.cli.core.commands.transform import register_global_transforms - from azure.cli.core._session import ACCOUNT, CONFIG, SESSION, INDEX, EXTENSION_INDEX, HELP_INDEX, VERSIONS + from azure.cli.core._session import ACCOUNT, CONFIG, SESSION, INDEX, EXTENSION_INDEX, HELP_INDEX, EXTENSION_HELP_INDEX, VERSIONS from azure.cli.core.util import handle_version_update from knack.util import ensure_dir @@ -92,6 +92,7 @@ def __init__(self, **kwargs): INDEX.load(os.path.join(azure_folder, 'commandIndex.json')) EXTENSION_INDEX.load(os.path.join(azure_folder, 'extensionIndex.json')) HELP_INDEX.load(os.path.join(azure_folder, 'helpIndex.json')) + EXTENSION_HELP_INDEX.load(os.path.join(azure_folder, 'extensionHelpIndex.json')) VERSIONS.load(os.path.join(azure_folder, 'versionCheck.json')) handle_version_update() @@ -744,10 +745,11 @@ def __init__(self, cli_ctx=None): :param cli_ctx: Only needed when `get` or `update` is called. """ - from azure.cli.core._session import INDEX, EXTENSION_INDEX, HELP_INDEX + from azure.cli.core._session import INDEX, EXTENSION_INDEX, HELP_INDEX, EXTENSION_HELP_INDEX self.INDEX = INDEX self.EXTENSION_INDEX = EXTENSION_INDEX self.HELP_INDEX = HELP_INDEX + self.EXTENSION_HELP_INDEX = EXTENSION_HELP_INDEX if cli_ctx: self.version = __version__ self.cloud_profile = cli_ctx.cloud.profile @@ -782,6 +784,13 @@ def _is_extension_index_valid(self): return (index_version and index_version == self.version and cloud_profile and cloud_profile == self.cloud_profile) + def _is_extension_help_index_valid(self): + """Check if the extension help index version and cloud profile are valid.""" + index_version = self.EXTENSION_HELP_INDEX.get(self._COMMAND_INDEX_VERSION) + cloud_profile = self.EXTENSION_HELP_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, index=None): """Get top-level command names for tab completion optimization. @@ -918,6 +927,34 @@ def _blend_command_indices(core_index, extension_index): blended[cmd].append(mod) return blended + @staticmethod + def _blend_help_indices(base_help_index, extension_help_index): + """Blend packaged core help with extension-only help overlay.""" + blended = { + 'groups': dict((base_help_index or {}).get('groups') or {}), + 'commands': dict((base_help_index or {}).get('commands') or {}) + } + ext_help_index = extension_help_index or {} + for section in ('groups', 'commands'): + blended_section = blended[section] + for key, value in (ext_help_index.get(section) or {}).items(): + blended_section[key] = value + return blended + + @staticmethod + def _build_extension_help_overlay(base_help_index, full_help_index): + """Build extension-only help overlay by diffing full help against packaged core help.""" + overlay = {'groups': {}, 'commands': {}} + base_help_index = base_help_index or {} + full_help_index = full_help_index or {} + for section in ('groups', 'commands'): + base_section = base_help_index.get(section) or {} + full_section = full_help_index.get(section) or {} + for key, value in full_section.items(): + if key not in base_section or base_section[key] != value: + overlay[section][key] = value + return overlay + def _get_blended_latest_index(self): """Get effective index for latest profile by blending core and extension indices.""" if self.cloud_profile != 'latest': @@ -1095,20 +1132,39 @@ def get_help_index(self): :return: Dictionary mapping top-level commands to their short summaries, or None if not available """ if self.cloud_profile == 'latest': - # Prefer local cache if available and valid, as it may include extension-specific help entries. - if self._is_index_valid(): - help_index = self.HELP_INDEX.get(self._HELP_INDEX, {}) - if not help_index: - help_index = self._migrate_legacy_help_index() or {} - if help_index: - logger.debug("Using cached local help index with %d entries", len(help_index)) - return help_index - + # Packaged help is the base for latest profile. packaged_help_index = self._load_packaged_help_index() - if packaged_help_index: - logger.debug("Using packaged help index with %d entries", len(packaged_help_index)) - return packaged_help_index - return None + if not packaged_help_index: + # Defensive fallback to local cache if packaged asset is unavailable. + if self._is_index_valid(): + help_index = self.HELP_INDEX.get(self._HELP_INDEX, {}) + if not help_index: + help_index = self._migrate_legacy_help_index() or {} + if help_index: + logger.debug("Using cached local help index with %d entries", len(help_index)) + return help_index + return None + + if self._is_extension_help_index_valid(): + extension_help_index = self.EXTENSION_HELP_INDEX.get(self._HELP_INDEX, {}) + if extension_help_index: + logger.debug("Blending packaged help index with extension help overlay (%d groups, %d commands).", + len(extension_help_index.get('groups') or {}), + len(extension_help_index.get('commands') or {})) + return self._blend_help_indices(packaged_help_index, extension_help_index) + + # Clear stale overlay cache if schema exists but metadata is invalid. + if self.EXTENSION_HELP_INDEX.get(self._HELP_INDEX): + self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_VERSION] = "" + self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" + self.EXTENSION_HELP_INDEX[self._HELP_INDEX] = {} + + if self._has_non_always_loaded_extensions(): + logger.debug("Extension help overlay unavailable on latest profile. Triggering refresh via full load.") + return None + + logger.debug("Using packaged help index with %d entries", len(packaged_help_index)) + return packaged_help_index if not self._is_index_valid(): return None @@ -1127,6 +1183,20 @@ def set_help_index(self, help_data): :param help_data: Help index data structure containing groups and commands """ + if self.cloud_profile == 'latest': + packaged_help_index = self._load_packaged_help_index() or {'groups': {}, 'commands': {}} + extension_help_overlay = self._build_extension_help_overlay(packaged_help_index, help_data) + + self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_VERSION] = __version__ + self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = self.cloud_profile + self.EXTENSION_HELP_INDEX[self._HELP_INDEX] = extension_help_overlay + + # Keep local full help cache empty for latest; packaged base + extension overlay are authoritative. + self.HELP_INDEX[self._HELP_INDEX] = {} + if self.INDEX.get(self._HELP_INDEX): + self.INDEX[self._HELP_INDEX] = {} + return + self.HELP_INDEX[self._HELP_INDEX] = help_data # Clear legacy key if it exists in commandIndex.json. if self.INDEX.get(self._HELP_INDEX): @@ -1187,6 +1257,9 @@ def invalidate(self): self.EXTENSION_INDEX[self._COMMAND_INDEX_VERSION] = "" self.EXTENSION_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" self.EXTENSION_INDEX[self._COMMAND_INDEX] = {} + self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_VERSION] = "" + self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" + self.EXTENSION_HELP_INDEX[self._HELP_INDEX] = {} self.HELP_INDEX[self._HELP_INDEX] = {} # Clear legacy key if it exists in commandIndex.json. if self.INDEX.get(self._HELP_INDEX): diff --git a/src/azure-cli-core/azure/cli/core/_session.py b/src/azure-cli-core/azure/cli/core/_session.py index 356eddab690..294cc923f74 100644 --- a/src/azure-cli-core/azure/cli/core/_session.py +++ b/src/azure-cli-core/azure/cli/core/_session.py @@ -103,6 +103,9 @@ def __len__(self): # HELP_INDEX contains cached help summaries for top-level help display HELP_INDEX = Session() +# EXTENSION_HELP_INDEX contains extension-only help overlay for top-level help display +EXTENSION_HELP_INDEX = Session() + # VERSIONS provides local versions and pypi versions. # DO NOT USE it to get the current version of azure-cli, # it could be lagged behind and can be used to check whether diff --git a/src/azure-cli-core/azure/cli/core/mock.py b/src/azure-cli-core/azure/cli/core/mock.py index 1b2cf8515ad..89c80d3d372 100644 --- a/src/azure-cli-core/azure/cli/core/mock.py +++ b/src/azure-cli-core/azure/cli/core/mock.py @@ -33,7 +33,7 @@ def __init__(self, commands_loader_cls=None, random_config_dir=False, **kwargs): self.env_patch.start() # Always copy command index to avoid initializing it again - files_to_copy = ['commandIndex.json', 'extensionIndex.json', 'helpIndex.json'] + files_to_copy = ['commandIndex.json', 'extensionIndex.json', 'helpIndex.json', 'extensionHelpIndex.json'] # In recording mode, copy login credentials from global config dir to the dummy config dir if os.getenv(ENV_VAR_TEST_LIVE, '').lower() == 'true': files_to_copy.extend([ 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 5afde28cb32..31fad6d118a 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 @@ -164,7 +164,13 @@ def tearDown(self): shutil.rmtree(self._tempdirName) self.helps.clear() # Invalidate help cache to prevent test data from polluting production cache - from azure.cli.core._session import HELP_INDEX, INDEX + from azure.cli.core._session import EXTENSION_HELP_INDEX, HELP_INDEX, INDEX + if 'helpIndex' in EXTENSION_HELP_INDEX: + del EXTENSION_HELP_INDEX['helpIndex'] + if 'version' in EXTENSION_HELP_INDEX: + del EXTENSION_HELP_INDEX['version'] + if 'cloudProfile' in EXTENSION_HELP_INDEX: + del EXTENSION_HELP_INDEX['cloudProfile'] if 'helpIndex' in HELP_INDEX: del HELP_INDEX['helpIndex'] if 'helpIndex' in INDEX: @@ -543,8 +549,8 @@ def test_help_cache_extraction(self): 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 + """Test non-latest help cache remains local and retrievable.""" + from azure.cli.core import CommandIndex, __version__ from azure.cli.core._session import HELP_INDEX test_help_data = { @@ -556,8 +562,11 @@ def test_help_cache_storage_and_retrieval(self): } } - command_index = CommandIndex(self.test_cli) - command_index.set_help_index(test_help_data) + with mock.patch.object(self.test_cli.cloud, 'profile', '2019-03-01-hybrid'): + command_index = CommandIndex(self.test_cli) + command_index.version = __version__ + command_index.cloud_profile = '2019-03-01-hybrid' + command_index.set_help_index(test_help_data) retrieved = HELP_INDEX.get('helpIndex') @@ -570,7 +579,7 @@ def test_help_cache_storage_and_retrieval(self): def test_help_cache_invalidation(self): """Test that cache is invalidated correctly.""" from azure.cli.core import CommandIndex - from azure.cli.core._session import HELP_INDEX + from azure.cli.core._session import EXTENSION_HELP_INDEX, HELP_INDEX test_help_data = {'root': {'groups': {}, 'commands': {}}} command_index = CommandIndex(self.test_cli) @@ -581,9 +590,10 @@ def test_help_cache_invalidation(self): command_index.invalidate() self.assertEqual(HELP_INDEX.get('helpIndex'), {}) + self.assertEqual(EXTENSION_HELP_INDEX.get('helpIndex'), {}) def test_help_cache_legacy_migration(self): - """Test legacy helpIndex migration from commandIndex.json to helpIndex.json.""" + """Test legacy helpIndex migration from commandIndex.json to helpIndex.json for non-latest.""" from azure.cli.core import CommandIndex, __version__ from azure.cli.core._session import HELP_INDEX, INDEX @@ -592,12 +602,13 @@ def test_help_cache_legacy_migration(self): 'commands': {'legacy-cmd': {'summary': 'Legacy command', 'tags': ''}} } - command_index = CommandIndex(self.test_cli) - INDEX[CommandIndex._COMMAND_INDEX_VERSION] = __version__ - INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = self.test_cli.cloud.profile - INDEX['helpIndex'] = test_help_data + with mock.patch.object(self.test_cli.cloud, 'profile', '2019-03-01-hybrid'): + command_index = CommandIndex(self.test_cli) + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = __version__ + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = '2019-03-01-hybrid' + INDEX['helpIndex'] = test_help_data - migrated = command_index.get_help_index() + migrated = command_index.get_help_index() self.assertEqual(migrated, test_help_data) self.assertEqual(HELP_INDEX.get('helpIndex'), test_help_data) @@ -634,11 +645,65 @@ def test_help_index_uses_packaged_latest_without_local_index(self): 'commands': {'version': {'summary': 'Show version.', 'tags': ''}} } - with mock.patch.object(CommandIndex, '_load_packaged_help_index', return_value=packaged_help_data): + with mock.patch.object(CommandIndex, '_load_packaged_help_index', return_value=packaged_help_data), \ + mock.patch.object(CommandIndex, '_has_non_always_loaded_extensions', return_value=False): help_index = command_index.get_help_index() self.assertEqual(help_index, packaged_help_data) + def test_help_index_latest_missing_overlay_with_extensions_triggers_refresh(self): + """Test latest profile returns None to force refresh when extension help overlay is unavailable.""" + from azure.cli.core import CommandIndex + from azure.cli.core._session import EXTENSION_HELP_INDEX, HELP_INDEX, INDEX + + command_index = CommandIndex(self.test_cli) + + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + INDEX[CommandIndex._COMMAND_INDEX] = {} + HELP_INDEX[CommandIndex._HELP_INDEX] = {} + EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + EXTENSION_HELP_INDEX[CommandIndex._HELP_INDEX] = {} + + packaged_help_data = { + 'groups': {'vm': {'summary': 'Manage VMs.', 'tags': ''}}, + 'commands': {'version': {'summary': 'Show version.', 'tags': ''}} + } + + with mock.patch.object(CommandIndex, '_load_packaged_help_index', return_value=packaged_help_data), \ + mock.patch.object(CommandIndex, '_has_non_always_loaded_extensions', return_value=True): + help_index = command_index.get_help_index() + + self.assertIsNone(help_index) + + def test_help_index_latest_blends_packaged_with_extension_overlay(self): + """Test latest profile blends packaged help with extension help overlay.""" + from azure.cli.core import CommandIndex, __version__ + from azure.cli.core._session import EXTENSION_HELP_INDEX + + command_index = CommandIndex(self.test_cli) + + EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_VERSION] = __version__ + EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = self.test_cli.cloud.profile + EXTENSION_HELP_INDEX[CommandIndex._HELP_INDEX] = { + 'groups': {'ext-group': {'summary': 'Extension group summary.', 'tags': ''}}, + 'commands': {'ext-cmd': {'summary': 'Extension command summary.', 'tags': ''}} + } + + packaged_help_data = { + 'groups': {'vm': {'summary': 'Manage VMs.', 'tags': ''}}, + 'commands': {'version': {'summary': 'Show version.', 'tags': ''}} + } + + with mock.patch.object(CommandIndex, '_load_packaged_help_index', return_value=packaged_help_data): + help_index = command_index.get_help_index() + + self.assertIn('vm', help_index['groups']) + self.assertIn('ext-group', help_index['groups']) + self.assertIn('version', help_index['commands']) + self.assertIn('ext-cmd', help_index['commands']) + def test_show_cached_help_output(self): """Test that cached help is displayed correctly.""" from azure.cli.core._help import AzCliHelp From 39deee4d7ab61929957e55d19cf1d23a0ff75702 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 12 Mar 2026 13:16:34 +1100 Subject: [PATCH 03/26] fix: fix top-level help and fix help cache write conditions --- src/azure-cli-core/azure/cli/core/__init__.py | 66 ++++++++++++++----- .../core/tests/test_command_registration.py | 50 +++++++++++++- 2 files changed, 97 insertions(+), 19 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 05b8f5c6352..b6c97df4f6e 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -476,6 +476,14 @@ def _get_extension_suppressions(mod_loaders): # The index won't contain suppressed extensions _update_command_table_from_extensions([], index_extensions) + # If we loaded all extensions as a safety fallback, refresh extension overlay cache + # so subsequent runs can use blended targeted loading. + if use_command_index and index_extensions is None and command_index.cloud_profile == 'latest': + command_index.update_extension_index(self.command_table) + # We already paid the cost to load all extensions. Refresh help overlay as well so + # top-level help can stay on packaged base + extension overlay fast path. + self._cache_help_index(command_index) + logger.debug("Loaded %d groups, %d commands.", len(self.command_group_table), len(self.command_table)) from azure.cli.core.util import roughly_parse_command # The index may be outdated. Make sure the command appears in the loaded command table @@ -517,6 +525,15 @@ def _get_extension_suppressions(mod_loaders): logger.debug("Could not find a match in the command or command group table for '%s'. " "The index may be outdated.", raw_cmd) + + if command_index.cloud_profile == 'latest' and lookup_args and \ + not self.cli_ctx.data['completer_active']: + top_command = lookup_args[0] + packaged_core_index = command_index._get_packaged_command_index(ignore_extensions=True) or {} + if top_command != 'help' and top_command not in packaged_core_index: + logger.debug("Top-level command '%s' is not in packaged core index. " + "Skipping full core module reload.", top_command) + return self.command_table else: logger.debug("No module found from index for '%s'", args) @@ -1048,12 +1065,19 @@ def get(self, args): if result: return result - if force_load_all_extensions and normalized_args and not normalized_args[0].startswith('-') and \ - not self.cli_ctx.data['completer_active']: - logger.debug("No match found in blended latest index for '%s'. Loading all extensions.", - normalized_args[0]) - # Load all extensions to resolve extension-only top-level commands without rebuilding all modules. - return [], None + if normalized_args and not normalized_args[0].startswith('-') and \ + not self.cli_ctx.data['completer_active'] and not force_packaged_for_version and \ + top_command != 'help': + # Unknown top-level command on latest should prefer extension-only retry and avoid + # full core module rebuild to preserve packaged-index startup benefit. + if has_non_always_loaded_extensions: + logger.debug("No match found in blended latest index for '%s'. Loading all extensions.", + normalized_args[0]) + return [], None + + logger.debug("No match found in latest index for '%s' and no dynamic extensions are installed. " + "Skipping core module rebuild.", normalized_args[0]) + return [], [] logger.debug("No match found in blended latest index. Falling back to local command index.") @@ -1225,21 +1249,27 @@ def update(self, command_table): elapsed_time = timeit.default_timer() - start_time self.INDEX[self._COMMAND_INDEX] = index - # Maintain extension-only overlay for latest profile so packaged core can be blended. - if self.cloud_profile == 'latest': - extension_index = defaultdict(list) - for command_name, command in command_table.items(): - top_command = command_name.split()[0] - module_name = command.loader.__module__ - if module_name.startswith('azext_') and module_name not in extension_index[top_command]: - extension_index[top_command].append(module_name) - - self.EXTENSION_INDEX[self._COMMAND_INDEX_VERSION] = __version__ - self.EXTENSION_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = self.cloud_profile - self.EXTENSION_INDEX[self._COMMAND_INDEX] = extension_index + self.update_extension_index(command_table) logger.debug("Updated command index in %.3f seconds.", elapsed_time) + def update_extension_index(self, command_table): + """Update extension-only overlay index from a command table (latest profile only).""" + if self.cloud_profile != 'latest': + return + + from collections import defaultdict + extension_index = defaultdict(list) + for command_name, command in command_table.items(): + top_command = command_name.split()[0] + module_name = command.loader.__module__ + if module_name.startswith('azext_') and module_name not in extension_index[top_command]: + extension_index[top_command].append(module_name) + + self.EXTENSION_INDEX[self._COMMAND_INDEX_VERSION] = __version__ + self.EXTENSION_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = self.cloud_profile + self.EXTENSION_INDEX[self._COMMAND_INDEX] = extension_index + def invalidate(self): """Invalidate the command index. diff --git a/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py b/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py index b2b71be4a95..e1f0b828acf 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py @@ -535,7 +535,7 @@ def test_command_index_handles_leading_output_option(self): @mock.patch('azure.cli.core.extension.get_extension_modname', _mock_get_extension_modname) @mock.patch('azure.cli.core.extension.get_extensions', _mock_get_extensions) def test_command_index_loads_all_extensions_when_overlay_missing(self): - from azure.cli.core._session import INDEX + from azure.cli.core._session import INDEX, EXTENSION_INDEX, EXTENSION_HELP_INDEX from azure.cli.core import CommandIndex, __version__ cli = DummyCli() @@ -560,6 +560,54 @@ def test_command_index_loads_all_extensions_when_overlay_missing(self): # Missing overlay triggers loading all extensions, but avoids full module rebuild. self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden', 'hello ext-only']) self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) + self.assertEqual(EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_VERSION], __version__) + self.assertEqual(EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE], cli.cloud.profile) + self.assertIn('hello', EXTENSION_INDEX[CommandIndex._COMMAND_INDEX]) + self.assertIn('azext_hello1', EXTENSION_INDEX[CommandIndex._COMMAND_INDEX]['hello']) + self.assertIn('azext_hello2', EXTENSION_INDEX[CommandIndex._COMMAND_INDEX]['hello']) + self.assertEqual(EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_VERSION], __version__) + self.assertEqual(EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE], cli.cloud.profile) + self.assertIn('groups', EXTENSION_HELP_INDEX[CommandIndex._HELP_INDEX]) + self.assertIn('commands', EXTENSION_HELP_INDEX[CommandIndex._HELP_INDEX]) + + @mock.patch('importlib.import_module', _mock_import_lib) + @mock.patch('pkgutil.iter_modules', _mock_iter_modules) + @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) + @mock.patch('azure.cli.core.extension.get_extension_modname', _mock_get_extension_modname) + @mock.patch('azure.cli.core.extension.get_extensions', _mock_get_extensions) + def test_command_index_latest_unknown_non_core_skips_full_core_reload(self): + from azure.cli.core._session import INDEX, EXTENSION_INDEX + from azure.cli.core import CommandIndex, __version__ + + cli = DummyCli() + loader = cli.commands_loader + + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + INDEX[CommandIndex._COMMAND_INDEX] = {} + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX] = {} + + packaged_index = { + CommandIndex._COMMAND_INDEX_VERSION: __version__, + CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: cli.cloud.profile, + CommandIndex._COMMAND_INDEX: { + 'hello': ['azure.cli.command_modules.hello'], + 'extra': ['azure.cli.command_modules.extra'] + } + } + + with mock.patch.object(CommandIndex, '_load_packaged_command_index', return_value=packaged_index): + cmd_tbl = loader.load_command_table(["foobar", "list"]) + + # Unknown non-core top-level command should try extensions without rebuilding all core modules. + self.assertNotIn('hello mod-only', cmd_tbl) + self.assertNotIn('extra final', cmd_tbl) + self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) + self.assertEqual(EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_VERSION], __version__) + self.assertEqual(EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE], cli.cloud.profile) + self.assertIn('hello', EXTENSION_INDEX[CommandIndex._COMMAND_INDEX]) @mock.patch('importlib.import_module', _mock_import_lib) @mock.patch('pkgutil.iter_modules', _mock_iter_modules) From b3bfc32caf7b34978cc00d1db35627a5707f7491 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 13 Mar 2026 16:18:56 +1100 Subject: [PATCH 04/26] fix: remove normalize_args method --- src/azure-cli-core/azure/cli/core/__init__.py | 61 ++--------------- .../core/tests/test_command_registration.py | 68 ++----------------- 2 files changed, 12 insertions(+), 117 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index b6c97df4f6e..4bc12d96efb 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -459,7 +459,7 @@ def _get_extension_suppressions(mod_loaders): if use_command_index: command_index = CommandIndex(self.cli_ctx) - lookup_args = command_index._normalize_args_for_index_lookup(args) # pylint: disable=protected-access + lookup_args = args index_result = command_index.get(args) if index_result: index_modules, index_extensions = index_result @@ -754,8 +754,6 @@ class CommandIndex: _HELP_INDEX = 'helpIndex' _PACKAGED_COMMAND_INDEX_LATEST = 'commandIndex.latest.json' _PACKAGED_HELP_INDEX_LATEST = 'helpIndex.latest.json' - _LEADING_GLOBAL_OPTS_WITH_VALUE = {'--output', '-o', '--query', '--subscription', '-s', '--tenant', '-t'} - _LEADING_GLOBAL_FLAG_OPTS = {'--debug', '--verbose', '--only-show-errors', '--help', '-h'} def __init__(self, cli_ctx=None): """Class to manage command index. @@ -998,59 +996,14 @@ def _get_blended_latest_index(self): logger.debug("Blending packaged core index with local extension index.") return self._blend_command_indices(core_index, extension_index), extension_index_available, has_non_always_loaded_extensions - @classmethod - def _normalize_args_for_index_lookup(cls, args): - """Trim leading global options so index lookup can find the top-level command.""" - if not args: - return args - - i = 0 - while i < len(args): - token = args[i] - if token == '--': - return args[i + 1:] - - if not token.startswith('-'): - return args[i:] - - if token.startswith('--'): - opt_name = token.split('=', 1)[0] - if '=' in token: - i += 1 - continue - if opt_name in cls._LEADING_GLOBAL_OPTS_WITH_VALUE: - i += 2 - continue - # Unknown long options are treated as flags here. If invalid, normal parser flow will raise later. - i += 1 - continue - - if token in cls._LEADING_GLOBAL_OPTS_WITH_VALUE: - i += 2 - continue - - if token in cls._LEADING_GLOBAL_FLAG_OPTS: - i += 1 - continue - - # Handle compact short options where value is attached, e.g. -ojson. - if len(token) > 2 and token[:2] in {'-o', '-s', '-t'}: - i += 1 - continue - - # Unknown short options are treated as flags here. - i += 1 - - return [] - def get(self, args): """Get the corresponding module and extension list of a command. :param args: command arguments, like ['network', 'vnet', 'create', '-h'] :return: a tuple containing a list of modules and a list of extensions. """ - normalized_args = self._normalize_args_for_index_lookup(args) - top_command = normalized_args[0] if normalized_args else None + + top_command = args[0] if args else None # Resolve effective index. # For latest profile, blend packaged core index with local extension index. @@ -1060,23 +1013,23 @@ def get(self, args): if index is not None: force_load_all_extensions = has_non_always_loaded_extensions and not extension_index_available and \ not force_packaged_for_version - result = self._lookup_command_in_index(index, normalized_args, + result = self._lookup_command_in_index(index, args, force_load_all_extensions=force_load_all_extensions) if result: return result - if normalized_args and not normalized_args[0].startswith('-') and \ + if args and not args[0].startswith('-') and \ not self.cli_ctx.data['completer_active'] and not force_packaged_for_version and \ top_command != 'help': # Unknown top-level command on latest should prefer extension-only retry and avoid # full core module rebuild to preserve packaged-index startup benefit. if has_non_always_loaded_extensions: logger.debug("No match found in blended latest index for '%s'. Loading all extensions.", - normalized_args[0]) + args[0]) return [], None logger.debug("No match found in latest index for '%s' and no dynamic extensions are installed. " - "Skipping core module rebuild.", normalized_args[0]) + "Skipping core module rebuild.", args[0]) return [], [] logger.debug("No match found in blended latest index. Falling back to local command index.") diff --git a/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py b/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py index e1f0b828acf..35e32341f9c 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py @@ -471,75 +471,21 @@ def test_command_index_uses_packaged_latest_without_seeding(self): self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden']) self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) - @mock.patch('importlib.import_module', _mock_import_lib) - @mock.patch('pkgutil.iter_modules', _mock_iter_modules) - @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) - @mock.patch('azure.cli.core.extension.get_extensions', _mock_no_extensions) - def test_command_index_handles_leading_debug_flag(self): - from azure.cli.core._session import INDEX - from azure.cli.core import CommandIndex, __version__ - - cli = DummyCli() - loader = cli.commands_loader - - INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" - INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" - INDEX[CommandIndex._COMMAND_INDEX] = {} - - packaged_index = { - CommandIndex._COMMAND_INDEX_VERSION: __version__, - CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: cli.cloud.profile, - CommandIndex._COMMAND_INDEX: { - 'hello': ['azure.cli.command_modules.hello'] - } - } - - with mock.patch.object(CommandIndex, '_load_packaged_command_index', return_value=packaged_index): - cmd_tbl = loader.load_command_table(["--debug", "hello", "mod-only"]) - - self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden']) - self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) - - @mock.patch('importlib.import_module', _mock_import_lib) - @mock.patch('pkgutil.iter_modules', _mock_iter_modules) - @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) - @mock.patch('azure.cli.core.extension.get_extensions', _mock_no_extensions) - def test_command_index_handles_leading_output_option(self): - from azure.cli.core._session import INDEX - from azure.cli.core import CommandIndex, __version__ - - cli = DummyCli() - loader = cli.commands_loader - - INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" - INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" - INDEX[CommandIndex._COMMAND_INDEX] = {} - - packaged_index = { - CommandIndex._COMMAND_INDEX_VERSION: __version__, - CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: cli.cloud.profile, - CommandIndex._COMMAND_INDEX: { - 'hello': ['azure.cli.command_modules.hello'] - } - } - - with mock.patch.object(CommandIndex, '_load_packaged_command_index', return_value=packaged_index): - cmd_tbl = loader.load_command_table(["-o", "json", "hello", "mod-only"]) - - self.assertEqual(list(cmd_tbl), ['hello mod-only', 'hello overridden']) - self.assertEqual(INDEX[CommandIndex._COMMAND_INDEX], {}) - @mock.patch('importlib.import_module', _mock_import_lib) @mock.patch('pkgutil.iter_modules', _mock_iter_modules) @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) @mock.patch('azure.cli.core.extension.get_extension_modname', _mock_get_extension_modname) @mock.patch('azure.cli.core.extension.get_extensions', _mock_get_extensions) def test_command_index_loads_all_extensions_when_overlay_missing(self): - from azure.cli.core._session import INDEX, EXTENSION_INDEX, EXTENSION_HELP_INDEX + from azure.cli.core._session import INDEX, EXTENSION_INDEX from azure.cli.core import CommandIndex, __version__ cli = DummyCli() loader = cli.commands_loader + cli.invocation = cli.invocation_cls(cli_ctx=cli, + commands_loader_cls=cli.commands_loader_cls, + parser_cls=cli.parser_cls, + help_cls=cli.help_cls) INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" @@ -565,10 +511,6 @@ def test_command_index_loads_all_extensions_when_overlay_missing(self): self.assertIn('hello', EXTENSION_INDEX[CommandIndex._COMMAND_INDEX]) self.assertIn('azext_hello1', EXTENSION_INDEX[CommandIndex._COMMAND_INDEX]['hello']) self.assertIn('azext_hello2', EXTENSION_INDEX[CommandIndex._COMMAND_INDEX]['hello']) - self.assertEqual(EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_VERSION], __version__) - self.assertEqual(EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE], cli.cloud.profile) - self.assertIn('groups', EXTENSION_HELP_INDEX[CommandIndex._HELP_INDEX]) - self.assertIn('commands', EXTENSION_HELP_INDEX[CommandIndex._HELP_INDEX]) @mock.patch('importlib.import_module', _mock_import_lib) @mock.patch('pkgutil.iter_modules', _mock_iter_modules) From 850f96ab7b1c01c4e97c630c07f3430ce91ff8a4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 13 Mar 2026 16:22:54 +1100 Subject: [PATCH 05/26] fix: remove redundant var --- src/azure-cli-core/azure/cli/core/__init__.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 4bc12d96efb..4452e21c1e9 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -459,7 +459,6 @@ def _get_extension_suppressions(mod_loaders): if use_command_index: command_index = CommandIndex(self.cli_ctx) - lookup_args = args index_result = command_index.get(args) if index_result: index_modules, index_extensions = index_result @@ -471,7 +470,7 @@ def _get_extension_suppressions(mod_loaders): # Always load modules and extensions, because some of them (like those in # ALWAYS_LOADED_EXTENSIONS) don't expose a command, but hooks into handlers in CLI core - _update_command_table_from_modules(lookup_args, index_modules) + _update_command_table_from_modules(args, index_modules) # The index won't contain suppressed extensions _update_command_table_from_extensions([], index_extensions) @@ -487,7 +486,7 @@ def _get_extension_suppressions(mod_loaders): logger.debug("Loaded %d groups, %d commands.", len(self.command_group_table), len(self.command_table)) from azure.cli.core.util import roughly_parse_command # The index may be outdated. Make sure the command appears in the loaded command table - raw_cmd = roughly_parse_command(lookup_args) + raw_cmd = roughly_parse_command(args) for cmd in self.command_table: if raw_cmd.startswith(cmd): # For commands with positional arguments, the raw command won't match the one in the @@ -526,9 +525,9 @@ def _get_extension_suppressions(mod_loaders): logger.debug("Could not find a match in the command or command group table for '%s'. " "The index may be outdated.", raw_cmd) - if command_index.cloud_profile == 'latest' and lookup_args and \ + if command_index.cloud_profile == 'latest' and args and \ not self.cli_ctx.data['completer_active']: - top_command = lookup_args[0] + top_command = args[0] packaged_core_index = command_index._get_packaged_command_index(ignore_extensions=True) or {} if top_command != 'help' and top_command not in packaged_core_index: logger.debug("Top-level command '%s' is not in packaged core index. " @@ -1047,7 +1046,7 @@ def get(self, args): self.invalidate() return None - return self._lookup_command_in_index(index, normalized_args) + return self._lookup_command_in_index(index, args) def _lookup_command_in_index(self, index, args, force_load_all_extensions=False): """Lookup command modules/extensions from a resolved index mapping.""" From ccf1adc6aa7fbe6706169338e9376c829c95283d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Mar 2026 16:51:23 +1100 Subject: [PATCH 06/26] refactor: extract cache invalidation methods --- src/azure-cli-core/azure/cli/core/__init__.py | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 4452e21c1e9..d66d97987d7 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -805,6 +805,18 @@ def _is_extension_help_index_valid(self): return (index_version and index_version == self.version and cloud_profile and cloud_profile == self.cloud_profile) + def _clear_extension_index_cache(self): + """Clear extension command index cache metadata and payload.""" + self.EXTENSION_INDEX[self._COMMAND_INDEX_VERSION] = "" + self.EXTENSION_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" + self.EXTENSION_INDEX[self._COMMAND_INDEX] = {} + + def _clear_extension_help_overlay_cache(self): + """Clear extension help overlay cache metadata and payload.""" + self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_VERSION] = "" + self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" + self.EXTENSION_HELP_INDEX[self._HELP_INDEX] = {} + def _get_top_level_completion_commands(self, index=None): """Get top-level command names for tab completion optimization. @@ -987,9 +999,7 @@ def _get_blended_latest_index(self): else: if self.EXTENSION_INDEX.get(self._COMMAND_INDEX): logger.debug("Extension index version or cloud profile is invalid, clearing local extension index.") - self.EXTENSION_INDEX[self._COMMAND_INDEX_VERSION] = "" - self.EXTENSION_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" - self.EXTENSION_INDEX[self._COMMAND_INDEX] = {} + self._clear_extension_index_cache() if extension_index: logger.debug("Blending packaged core index with local extension index.") @@ -1131,9 +1141,7 @@ def get_help_index(self): # Clear stale overlay cache if schema exists but metadata is invalid. if self.EXTENSION_HELP_INDEX.get(self._HELP_INDEX): - self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_VERSION] = "" - self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" - self.EXTENSION_HELP_INDEX[self._HELP_INDEX] = {} + self._clear_extension_help_overlay_cache() if self._has_non_always_loaded_extensions(): logger.debug("Extension help overlay unavailable on latest profile. Triggering refresh via full load.") @@ -1236,12 +1244,8 @@ def invalidate(self): self.INDEX[self._COMMAND_INDEX_VERSION] = "" self.INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" self.INDEX[self._COMMAND_INDEX] = {} - self.EXTENSION_INDEX[self._COMMAND_INDEX_VERSION] = "" - self.EXTENSION_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" - self.EXTENSION_INDEX[self._COMMAND_INDEX] = {} - self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_VERSION] = "" - self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = "" - self.EXTENSION_HELP_INDEX[self._HELP_INDEX] = {} + self._clear_extension_index_cache() + self._clear_extension_help_overlay_cache() self.HELP_INDEX[self._HELP_INDEX] = {} # Clear legacy key if it exists in commandIndex.json. if self.INDEX.get(self._HELP_INDEX): From 195ff4b9401554eb3b9284625441485ae6d76c26 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 17 Mar 2026 17:06:37 +1100 Subject: [PATCH 07/26] refactor: remove helpIndex migration --- src/azure-cli-core/azure/cli/core/__init__.py | 16 ---------------- .../azure/cli/core/tests/test_help.py | 12 ++++++------ 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index d66d97987d7..fdb4637acce 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -769,18 +769,6 @@ def __init__(self, cli_ctx=None): self.cloud_profile = cli_ctx.cloud.profile self.cli_ctx = cli_ctx - def _migrate_legacy_help_index(self): - """Migrate help cache from legacy commandIndex.json storage to helpIndex.json.""" - legacy_help_index = self.INDEX.get(self._HELP_INDEX) - if not legacy_help_index: - return None - - logger.debug("Migrating help index cache from commandIndex.json to helpIndex.json") - self.HELP_INDEX[self._HELP_INDEX] = legacy_help_index - # Keep commandIndex.json focused on command routing data. - self.INDEX[self._HELP_INDEX] = {} - return legacy_help_index - def _is_index_valid(self): """Check if the command index version and cloud profile are valid. @@ -1124,8 +1112,6 @@ def get_help_index(self): # Defensive fallback to local cache if packaged asset is unavailable. if self._is_index_valid(): help_index = self.HELP_INDEX.get(self._HELP_INDEX, {}) - if not help_index: - help_index = self._migrate_legacy_help_index() or {} if help_index: logger.debug("Using cached local help index with %d entries", len(help_index)) return help_index @@ -1154,8 +1140,6 @@ def get_help_index(self): return None help_index = self.HELP_INDEX.get(self._HELP_INDEX, {}) - if not help_index: - help_index = self._migrate_legacy_help_index() or {} if help_index: logger.debug("Using cached help index with %d entries", len(help_index)) return help_index 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 31fad6d118a..ba9505c1cb4 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 @@ -592,8 +592,8 @@ def test_help_cache_invalidation(self): self.assertEqual(HELP_INDEX.get('helpIndex'), {}) self.assertEqual(EXTENSION_HELP_INDEX.get('helpIndex'), {}) - def test_help_cache_legacy_migration(self): - """Test legacy helpIndex migration from commandIndex.json to helpIndex.json for non-latest.""" + def test_help_cache_legacy_command_index_is_ignored(self): + """Test legacy helpIndex payload in commandIndex.json is not migrated for non-latest.""" from azure.cli.core import CommandIndex, __version__ from azure.cli.core._session import HELP_INDEX, INDEX @@ -608,11 +608,11 @@ def test_help_cache_legacy_migration(self): INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = '2019-03-01-hybrid' INDEX['helpIndex'] = test_help_data - migrated = command_index.get_help_index() + cached_help = command_index.get_help_index() - self.assertEqual(migrated, test_help_data) - self.assertEqual(HELP_INDEX.get('helpIndex'), test_help_data) - self.assertEqual(INDEX.get('helpIndex'), {}) + self.assertIsNone(cached_help) + self.assertNotEqual(HELP_INDEX.get('helpIndex'), test_help_data) + self.assertEqual(INDEX.get('helpIndex'), test_help_data) def test_packaged_help_index_file_schema(self): """Test packaged helpIndex.latest.json schema and metadata.""" From 1d2d531e1e8c23e6b9ef74a292762e6427b03c01 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Mar 2026 14:57:33 +1100 Subject: [PATCH 08/26] refactor: remove helpIndex migration --- src/azure-cli-core/azure/cli/core/__init__.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index fdb4637acce..36584eaf5d7 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -256,6 +256,13 @@ def _update_command_definitions(self): loader.command_table = self.command_table loader._update_command_definitions() # pylint: disable=protected-access + @staticmethod + def _should_update_extension_index(index_extensions, command_index): + """Return True when latest-profile extension overlays should be refreshed.""" + return (index_extensions is None and + command_index is not None and + command_index.cloud_profile == 'latest') + # pylint: disable=too-many-statements, too-many-locals def load_command_table(self, args): from importlib import import_module @@ -475,12 +482,8 @@ def _get_extension_suppressions(mod_loaders): # The index won't contain suppressed extensions _update_command_table_from_extensions([], index_extensions) - # If we loaded all extensions as a safety fallback, refresh extension overlay cache - # so subsequent runs can use blended targeted loading. - if use_command_index and index_extensions is None and command_index.cloud_profile == 'latest': + if self._should_update_extension_index(index_extensions, command_index): command_index.update_extension_index(self.command_table) - # We already paid the cost to load all extensions. Refresh help overlay as well so - # top-level help can stay on packaged base + extension overlay fast path. self._cache_help_index(command_index) logger.debug("Loaded %d groups, %d commands.", len(self.command_group_table), len(self.command_table)) From ca1a9a61eeb35f43a58b7b91123a969aa7a58b26 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Mar 2026 16:10:44 +1100 Subject: [PATCH 09/26] refactor: convenience method for fallback log --- src/azure-cli-core/azure/cli/core/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 36584eaf5d7..43188162f7e 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -263,6 +263,13 @@ def _should_update_extension_index(index_extensions, command_index): command_index is not None and command_index.cloud_profile == 'latest') + def _is_latest_non_completion_invocation(self, command_index, args): + """Return True for real latest-profile invocations (not shell completion).""" + return (command_index is not None and + command_index.cloud_profile == 'latest' and + bool(args) and + not self.cli_ctx.data['completer_active']) + # pylint: disable=too-many-statements, too-many-locals def load_command_table(self, args): from importlib import import_module @@ -528,8 +535,7 @@ def _get_extension_suppressions(mod_loaders): logger.debug("Could not find a match in the command or command group table for '%s'. " "The index may be outdated.", raw_cmd) - if command_index.cloud_profile == 'latest' and args and \ - not self.cli_ctx.data['completer_active']: + if self._is_latest_non_completion_invocation(command_index, args): top_command = args[0] packaged_core_index = command_index._get_packaged_command_index(ignore_extensions=True) or {} if top_command != 'help' and top_command not in packaged_core_index: From 24cba2e1f4d4eee69f7224bb60ab374d40239ee7 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Mar 2026 16:24:00 +1100 Subject: [PATCH 10/26] refactor: move methods for readability --- src/azure-cli-core/azure/cli/core/__init__.py | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 43188162f7e..ecb3d17e1d7 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -831,6 +831,20 @@ def _get_top_level_completion_commands(self, index=None): logger.debug("Top-level completion: %d commands available", len(top_level_commands)) return TOP_LEVEL_COMPLETION_MARKER, top_level_commands + def _can_use_packaged_command_index(self, ignore_extensions=False): + """Whether packaged command index can be used safely for this invocation.""" + if self.cloud_profile != 'latest': + return False + + if ignore_extensions: + return True + + # If non-always-loaded extensions are installed, we need a full rebuild to include overrides/extensions. + if self._has_non_always_loaded_extensions(): + return False + + return True + def _load_packaged_command_index(self): """Load packaged command index for latest profile if present.""" file_path = os.path.join(os.path.dirname(__file__), self._PACKAGED_COMMAND_INDEX_LATEST) @@ -882,20 +896,6 @@ def _load_packaged_help_index(self): return help_index - def _can_use_packaged_command_index(self, ignore_extensions=False): - """Whether packaged command index can be used safely for this invocation.""" - if self.cloud_profile != 'latest': - return False - - if ignore_extensions: - return True - - # If non-always-loaded extensions are installed, we need a full rebuild to include overrides/extensions. - if self._has_non_always_loaded_extensions(): - return False - - return True - @staticmethod def _has_non_always_loaded_extensions(): """Return True if a non-always-loaded extension is installed.""" From bfe42cdbaa0a11cb42d09e98427c21451e98066f Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Mar 2026 16:53:17 +1100 Subject: [PATCH 11/26] refactor: extract latest .get method helper --- src/azure-cli-core/azure/cli/core/__init__.py | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index ecb3d17e1d7..cf45a5640ea 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -1002,6 +1002,39 @@ def _get_blended_latest_index(self): logger.debug("Blending packaged core index with local extension index.") return self._blend_command_indices(core_index, extension_index), extension_index_available, has_non_always_loaded_extensions + def _resolve_latest_index_lookup(self, args, top_command): + """Resolve command lookup for latest profile using blended packaged and extension indices.""" + force_packaged_for_version = bool(top_command == 'version') + index, extension_index_available, has_non_always_loaded_extensions = self._get_blended_latest_index() + if index is None: + return None + + force_load_all_extensions = (has_non_always_loaded_extensions and + not extension_index_available and + not force_packaged_for_version) + result = self._lookup_command_in_index(index, args, + force_load_all_extensions=force_load_all_extensions) + if result: + return result + + if (args and not args[0].startswith('-') and + not self.cli_ctx.data['completer_active'] and + not force_packaged_for_version and + top_command != 'help'): + # Unknown top-level command on latest should prefer extension-only retry and avoid + # full core module rebuild to preserve packaged-index startup benefit. + if has_non_always_loaded_extensions: + logger.debug("No match found in blended latest index for '%s'. Loading all extensions.", + args[0]) + return [], None + + logger.debug("No match found in latest index for '%s' and no dynamic extensions are installed. " + "Skipping core module rebuild.", args[0]) + return [], [] + + logger.debug("No match found in blended latest index. Falling back to local command index.") + return None + def get(self, args): """Get the corresponding module and extension list of a command. @@ -1011,34 +1044,10 @@ def get(self, args): top_command = args[0] if args else None - # Resolve effective index. - # For latest profile, blend packaged core index with local extension index. if self.cloud_profile == 'latest': - force_packaged_for_version = bool(top_command == 'version') - index, extension_index_available, has_non_always_loaded_extensions = self._get_blended_latest_index() - if index is not None: - force_load_all_extensions = has_non_always_loaded_extensions and not extension_index_available and \ - not force_packaged_for_version - result = self._lookup_command_in_index(index, args, - force_load_all_extensions=force_load_all_extensions) - if result: - return result - - if args and not args[0].startswith('-') and \ - not self.cli_ctx.data['completer_active'] and not force_packaged_for_version and \ - top_command != 'help': - # Unknown top-level command on latest should prefer extension-only retry and avoid - # full core module rebuild to preserve packaged-index startup benefit. - if has_non_always_loaded_extensions: - logger.debug("No match found in blended latest index for '%s'. Loading all extensions.", - args[0]) - return [], None - - logger.debug("No match found in latest index for '%s' and no dynamic extensions are installed. " - "Skipping core module rebuild.", args[0]) - return [], [] - - logger.debug("No match found in blended latest index. Falling back to local command index.") + latest_result = self._resolve_latest_index_lookup(args, top_command) + if latest_result is not None: + return latest_result # For non-latest, use local command index and fallback logic. index = None From 44f89c5c62c6265e550a094db35ed8eb7e77b1a9 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 18 Mar 2026 17:12:35 +1100 Subject: [PATCH 12/26] refactor: extract help index helpers --- src/azure-cli-core/azure/cli/core/__init__.py | 80 ++++++++++--------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index cf45a5640ea..e2ab9293e12 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -1118,51 +1118,57 @@ def _lookup_command_in_index(self, index, args, force_load_all_extensions=False) return None - def get_help_index(self): - """Get the help index for top-level help display. + def _get_help_index_cached_local(self, latest_fallback=False): + """Return cached local help index when available and index metadata is valid.""" + if not self._is_index_valid(): + return None - :return: Dictionary mapping top-level commands to their short summaries, or None if not available - """ - if self.cloud_profile == 'latest': - # Packaged help is the base for latest profile. - packaged_help_index = self._load_packaged_help_index() - if not packaged_help_index: - # Defensive fallback to local cache if packaged asset is unavailable. - if self._is_index_valid(): - help_index = self.HELP_INDEX.get(self._HELP_INDEX, {}) - if help_index: - logger.debug("Using cached local help index with %d entries", len(help_index)) - return help_index - return None + help_index = self.HELP_INDEX.get(self._HELP_INDEX, {}) + if not help_index: + return None - if self._is_extension_help_index_valid(): - extension_help_index = self.EXTENSION_HELP_INDEX.get(self._HELP_INDEX, {}) - if extension_help_index: - logger.debug("Blending packaged help index with extension help overlay (%d groups, %d commands).", - len(extension_help_index.get('groups') or {}), - len(extension_help_index.get('commands') or {})) - return self._blend_help_indices(packaged_help_index, extension_help_index) + if latest_fallback: + logger.debug("Using cached local help index with %d entries", len(help_index)) + else: + logger.debug("Using cached help index with %d entries", len(help_index)) + return help_index - # Clear stale overlay cache if schema exists but metadata is invalid. - if self.EXTENSION_HELP_INDEX.get(self._HELP_INDEX): - self._clear_extension_help_overlay_cache() + def _get_help_index_latest(self): + """Return help index for latest profile using packaged help and extension overlay.""" + # Packaged help is the base for latest profile. + packaged_help_index = self._load_packaged_help_index() + if not packaged_help_index: + # Defensive fallback to local cache if packaged asset is unavailable. + return self._get_help_index_cached_local(latest_fallback=True) + + if self._is_extension_help_index_valid(): + extension_help_index = self.EXTENSION_HELP_INDEX.get(self._HELP_INDEX, {}) + if extension_help_index: + logger.debug("Blending packaged help index with extension help overlay (%d groups, %d commands).", + len(extension_help_index.get('groups') or {}), + len(extension_help_index.get('commands') or {})) + return self._blend_help_indices(packaged_help_index, extension_help_index) + + # Clear stale overlay cache if schema exists but metadata is invalid. + if self.EXTENSION_HELP_INDEX.get(self._HELP_INDEX): + self._clear_extension_help_overlay_cache() - if self._has_non_always_loaded_extensions(): - logger.debug("Extension help overlay unavailable on latest profile. Triggering refresh via full load.") - return None + if self._has_non_always_loaded_extensions(): + logger.debug("Extension help overlay unavailable on latest profile. Triggering refresh via full load.") + return None - logger.debug("Using packaged help index with %d entries", len(packaged_help_index)) - return packaged_help_index + logger.debug("Using packaged help index with %d entries", len(packaged_help_index)) + return packaged_help_index - if not self._is_index_valid(): - return None + def get_help_index(self): + """Get the help index for top-level help display. - help_index = self.HELP_INDEX.get(self._HELP_INDEX, {}) - if help_index: - logger.debug("Using cached help index with %d entries", len(help_index)) - return help_index + :return: Dictionary mapping top-level commands to their short summaries, or None if not available + """ + if self.cloud_profile == 'latest': + return self._get_help_index_latest() - return None + return self._get_help_index_cached_local() def set_help_index(self, help_data): """Set the help index data. From 3d6ddac0c82bd4a6ff1449bd0f0fb47e909b3822 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 19 Mar 2026 09:08:27 +1100 Subject: [PATCH 13/26] fix test: adjust to use non-latest profile for legacy commandIndex tests --- src/azure-cli-core/azure/cli/core/__init__.py | 2 +- .../azure/cli/core/tests/test_command_registration.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index e2ab9293e12..421e14d660b 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -1077,7 +1077,7 @@ def _lookup_command_in_index(self, index, args, force_load_all_extensions=False) # Get the top-level command, like `network` in `network vnet create -h` top_command = args[0].lower() - index = self.INDEX[self._COMMAND_INDEX] + index = index or {} # Check the command index for (command: [module]) mapping, like # "network": ["azure.cli.command_modules.natgateway", "azure.cli.command_modules.network", "azext_firewall"] diff --git a/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py b/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py index 35e32341f9c..b55e2154197 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py @@ -315,6 +315,8 @@ def test_command_index(self): from azure.cli.core import CommandIndex, __version__ cli = DummyCli() + # This test validates legacy local index rebuild behavior, not latest packaged-index shortcuts. + cli.cloud.profile = "2019-03-01-hybrid" loader = cli.commands_loader command_index = CommandIndex(cli) @@ -699,6 +701,8 @@ def test_command_index_positional_argument(self): from azure.cli.core import CommandIndex cli = DummyCli() + # Use a non-latest profile so command index rebuild/usage follows local index semantics. + cli.cloud.profile = "2019-03-01-hybrid" loader = cli.commands_loader index = CommandIndex() index.invalidate() From 29f7cbbdc50da5f64c17f9b6c93a5ed28ebee6f9 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 19 Mar 2026 09:17:39 +1100 Subject: [PATCH 14/26] fix: linting issues --- src/azure-cli-core/azure/cli/core/__init__.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 421e14d660b..05283e12f17 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -270,7 +270,7 @@ def _is_latest_non_completion_invocation(self, command_index, args): bool(args) and not self.cli_ctx.data['completer_active']) - # pylint: disable=too-many-statements, too-many-locals + # pylint: disable=too-many-statements, too-many-locals, too-many-return-statements def load_command_table(self, args): from importlib import import_module import pkgutil @@ -537,7 +537,7 @@ def _get_extension_suppressions(mod_loaders): if self._is_latest_non_completion_invocation(command_index, args): top_command = args[0] - packaged_core_index = command_index._get_packaged_command_index(ignore_extensions=True) or {} + packaged_core_index = command_index.get_packaged_core_index() or {} if top_command != 'help' and top_command not in packaged_core_index: logger.debug("Top-level command '%s' is not in packaged core index. " "Skipping full core module reload.", top_command) @@ -881,12 +881,13 @@ def _load_packaged_help_index(self): logger.debug("Packaged help index file '%s' has invalid schema.", file_path) return None - if data.get(self._COMMAND_INDEX_VERSION) != self.version: - logger.debug("Packaged help index version doesn't match current CLI version.") - return None - - if data.get(self._COMMAND_INDEX_CLOUD_PROFILE) != self.cloud_profile: - logger.debug("Packaged help index cloud profile doesn't match current cloud profile.") + version_matches = data.get(self._COMMAND_INDEX_VERSION) == self.version + profile_matches = data.get(self._COMMAND_INDEX_CLOUD_PROFILE) == self.cloud_profile + if not version_matches or not profile_matches: + if not version_matches: + logger.debug("Packaged help index version doesn't match current CLI version.") + if not profile_matches: + logger.debug("Packaged help index cloud profile doesn't match current cloud profile.") return None help_index = data.get(self._HELP_INDEX) @@ -938,6 +939,10 @@ def _get_packaged_command_index(self, ignore_extensions=False): logger.debug("Using packaged command index for profile '%s'.", self.cloud_profile) return index + def get_packaged_core_index(self): + """Get packaged core command index mapping, ignoring extension presence checks.""" + return self._get_packaged_command_index(ignore_extensions=True) + @staticmethod def _blend_command_indices(core_index, extension_index): """Blend packaged core index with local extension overlay index.""" From 690a623809fbf6c8ba6c34008c1e52517b38976c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 19 Mar 2026 16:43:13 +1100 Subject: [PATCH 15/26] feature: add script to generate/verify *.latest.json --- scripts/generate_latest_indices.py | 317 ++++++++++++++++ .../azure/cli/core/commandIndex.latest.json | 328 ++++++++--------- .../azure/cli/core/helpIndex.latest.json | 342 +++++++++--------- 3 files changed, 652 insertions(+), 335 deletions(-) create mode 100644 scripts/generate_latest_indices.py diff --git a/scripts/generate_latest_indices.py b/scripts/generate_latest_indices.py new file mode 100644 index 00000000000..e7cde4c8ec3 --- /dev/null +++ b/scripts/generate_latest_indices.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Generate or verify packaged latest command/help index assets. + +This script updates or validates: +- src/azure-cli-core/azure/cli/core/commandIndex.latest.json +- src/azure-cli-core/azure/cli/core/helpIndex.latest.json + +The script runs in an isolated temp AZURE_CONFIG_DIR and with extension directories +redirected to empty folders to avoid local machine state affecting output. +""" + +import argparse +import json +import os +import sys +import tempfile +from contextlib import contextmanager +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +CORE_DIR = REPO_ROOT / 'src' / 'azure-cli-core' / 'azure' / 'cli' / 'core' +COMMAND_INDEX_PATH = CORE_DIR / 'commandIndex.latest.json' +HELP_INDEX_PATH = CORE_DIR / 'helpIndex.latest.json' +CORE_COMMAND_MODULE_PREFIX = 'azure.cli.command_modules.' + + +def _bootstrap_repo_paths(): + """Ensure local source trees are importable when running from repo root.""" + source_roots = [ + REPO_ROOT / 'src' / 'azure-cli-core', + REPO_ROOT / 'src' / 'azure-cli', + REPO_ROOT / 'src' / 'azure-cli-telemetry', + REPO_ROOT / 'src' / 'azure-cli-testsdk', + ] + + for source_root in source_roots: + source_root_str = str(source_root) + if source_root_str not in sys.path: + sys.path.insert(0, source_root_str) + + +@contextmanager +def _isolated_cli_environment(): + """Temporarily isolate config/extension directories for deterministic output.""" + tracked_vars = ['AZURE_CONFIG_DIR', 'AZURE_EXTENSION_DIR'] + previous = {name: os.environ.get(name) for name in tracked_vars} + + with tempfile.TemporaryDirectory(prefix='az-index-gen-') as temp_config_dir: + extension_dir = os.path.join(temp_config_dir, 'cliextensions') + os.makedirs(extension_dir, exist_ok=True) + + os.environ['AZURE_CONFIG_DIR'] = temp_config_dir + os.environ['AZURE_EXTENSION_DIR'] = extension_dir + + try: + yield temp_config_dir, extension_dir + finally: + for name, value in previous.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + + +def _read_json(path): + if not path.is_file(): + return None + with path.open('r', encoding='utf-8-sig') as handle: + return json.load(handle) + + +def _order_keys_like_template(generated, template): + """Preserve existing key order when possible, append new keys in sorted order.""" + if not isinstance(generated, dict): + return generated + + if not isinstance(template, dict): + return {key: generated[key] for key in sorted(generated)} + + ordered = {} + for key in template: + if key in generated: + ordered[key] = generated[key] + + for key in sorted(generated): + if key not in ordered: + ordered[key] = generated[key] + + return ordered + + +def _extract_builtin_module_name(command): + """Return built-in module name for a command table entry, or None for extension entries.""" + command_source = getattr(command, 'command_source', None) + if isinstance(command_source, str) and command_source.startswith(CORE_COMMAND_MODULE_PREFIX): + return command_source + + command_loader = getattr(command, 'loader', None) + loader_module = getattr(command_loader, '__module__', None) + if isinstance(loader_module, str) and loader_module.startswith(CORE_COMMAND_MODULE_PREFIX): + return loader_module + + return None + + +def _build_command_index_map(command_table): + command_index = {} + for command_name, command in command_table.items(): + top_command = command_name.split()[0] + module_name = _extract_builtin_module_name(command) + if not module_name: + continue + + modules = command_index.setdefault(top_command, []) + if module_name not in modules: + modules.append(module_name) + + for top_command, modules in command_index.items(): + command_index[top_command] = sorted(modules) + + return command_index + + +def _build_help_index_map(cli_ctx, commands_loader): + from azure.cli.core._help import CliGroupHelpFile, extract_help_index_data + from azure.cli.core.parser import AzCliCommandParser + + parser = AzCliCommandParser(cli_ctx) + parser.load_command_table(commands_loader) + + root_subparser = parser.subparsers.get(tuple()) + if not root_subparser: + return {'groups': {}, 'commands': {}} + + help_obj = cli_ctx.help_cls(cli_ctx) + root_help = CliGroupHelpFile(help_obj, '', root_subparser) + root_help.load(root_subparser) + + groups, commands = extract_help_index_data(root_help) + + normalized_groups = { + group_name: { + 'summary': group_data.get('summary', ''), + 'tags': group_data.get('tags', '') + } + for group_name, group_data in groups.items() + } + normalized_commands = { + command_name: { + 'summary': command_data.get('summary', ''), + 'tags': command_data.get('tags', '') + } + for command_name, command_data in commands.items() + } + + return { + 'groups': {key: normalized_groups[key] for key in sorted(normalized_groups)}, + 'commands': {key: normalized_commands[key] for key in sorted(normalized_commands)} + } + + +def _generate_documents(): + _bootstrap_repo_paths() + + with _isolated_cli_environment() as (temp_config_dir, extension_dir): + from azure.cli.core import CommandIndex, __version__, get_default_cli + import azure.cli.core.extension as extension_module + + # Hard pin extension discovery directories so local/global installed extensions do not leak in. + extension_module.EXTENSIONS_DIR = extension_dir + extension_module.EXTENSIONS_SYS_DIR = os.path.join(temp_config_dir, 'empty-system-extensions') + extension_module.DEV_EXTENSION_SOURCES = [] + os.makedirs(extension_module.EXTENSIONS_SYS_DIR, exist_ok=True) + + cli = get_default_cli() + cli.cloud.profile = 'latest' + cli.data['completer_active'] = False + + invoker = cli.invocation_cls( + cli_ctx=cli, + commands_loader_cls=cli.commands_loader_cls, + parser_cls=cli.parser_cls, + help_cls=cli.help_cls + ) + cli.invocation = invoker + commands_loader = invoker.commands_loader + command_table = commands_loader.load_command_table(None) + + current_command_doc = _read_json(COMMAND_INDEX_PATH) or {} + current_help_doc = _read_json(HELP_INDEX_PATH) or {} + + generated_command_index = _build_command_index_map(command_table) + generated_help_index = _build_help_index_map(cli, commands_loader) + + ordered_command_index = _order_keys_like_template( + generated_command_index, + current_command_doc.get(CommandIndex._COMMAND_INDEX) # pylint: disable=protected-access + ) + + help_template = current_help_doc.get(CommandIndex._HELP_INDEX, {}) # pylint: disable=protected-access + ordered_help_groups = _order_keys_like_template(generated_help_index['groups'], help_template.get('groups')) + ordered_help_commands = _order_keys_like_template(generated_help_index['commands'], help_template.get('commands')) + + command_doc = { + CommandIndex._COMMAND_INDEX_VERSION: __version__, # pylint: disable=protected-access + CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: 'latest', # pylint: disable=protected-access + CommandIndex._COMMAND_INDEX: ordered_command_index # pylint: disable=protected-access + } + + help_doc = { + CommandIndex._COMMAND_INDEX_VERSION: __version__, # pylint: disable=protected-access + CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: 'latest', # pylint: disable=protected-access + CommandIndex._HELP_INDEX: { # pylint: disable=protected-access + 'groups': ordered_help_groups, + 'commands': ordered_help_commands + } + } + + return command_doc, help_doc + + +def _serialize_json(document): + return json.dumps(document, indent=2) + '\n' + + +def _write_file(path, content): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open('w', encoding='utf-8', newline='\n') as handle: + handle.write(content) + + +def _load_text(path): + if not path.is_file(): + return None + return path.read_text(encoding='utf-8-sig') + + +def _run_generate(command_text, help_text): + current_command_text = _load_text(COMMAND_INDEX_PATH) + current_help_text = _load_text(HELP_INDEX_PATH) + + updated_files = [] + + if current_command_text != command_text: + _write_file(COMMAND_INDEX_PATH, command_text) + updated_files.append(COMMAND_INDEX_PATH) + + if current_help_text != help_text: + _write_file(HELP_INDEX_PATH, help_text) + updated_files.append(HELP_INDEX_PATH) + + if updated_files: + print('Updated generated latest index files:') + for path in updated_files: + print(f' - {path.relative_to(REPO_ROOT)}') + else: + print('Latest index files are already up-to-date.') + + return 0 + + +def _run_verify(command_text, help_text): + mismatched = [] + + if _load_text(COMMAND_INDEX_PATH) != command_text: + mismatched.append(COMMAND_INDEX_PATH) + if _load_text(HELP_INDEX_PATH) != help_text: + mismatched.append(HELP_INDEX_PATH) + + if mismatched: + print('Generated latest index files are out of date:') + for path in mismatched: + print(f' - {path.relative_to(REPO_ROOT)}') + print('Run:') + print(' env\\Scripts\\python.exe scripts\\generate_latest_indices.py generate') + return 1 + + print('Verified: latest index files are up-to-date.') + return 0 + + +def _parse_args(): + parser = argparse.ArgumentParser( + description='Generate or verify packaged latest command and help index JSON files.' + ) + parser.add_argument( + 'mode', + nargs='?', + choices=['generate', 'verify'], + default='generate', + help='Mode to run. generate writes files; verify checks drift and exits non-zero on mismatch.' + ) + return parser.parse_args() + + +def main(): + args = _parse_args() + + command_doc, help_doc = _generate_documents() + command_text = _serialize_json(command_doc) + help_text = _serialize_json(help_doc) + + if args.mode == 'verify': + return _run_verify(command_text, help_text) + + return _run_generate(command_text, help_text) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/azure-cli-core/azure/cli/core/commandIndex.latest.json b/src/azure-cli-core/azure/cli/core/commandIndex.latest.json index 1709a1a0292..a7242471e04 100644 --- a/src/azure-cli-core/azure/cli/core/commandIndex.latest.json +++ b/src/azure-cli-core/azure/cli/core/commandIndex.latest.json @@ -2,20 +2,37 @@ "version": "2.84.0", "cloudProfile": "latest", "commandIndex": { + "account": [ + "azure.cli.command_modules.profile", + "azure.cli.command_modules.resource" + ], + "acr": [ + "azure.cli.command_modules.acr" + ], + "ad": [ + "azure.cli.command_modules.role" + ], "advisor": [ "azure.cli.command_modules.advisor" ], + "afd": [ + "azure.cli.command_modules.cdn" + ], + "aks": [ + "azure.cli.command_modules.serviceconnector", + "azure.cli.command_modules.acs" + ], "ams": [ "azure.cli.command_modules.ams" ], - "appconfig": [ - "azure.cli.command_modules.appconfig" - ], "apim": [ "azure.cli.command_modules.apim" ], - "acr": [ - "azure.cli.command_modules.acr" + "appconfig": [ + "azure.cli.command_modules.appconfig" + ], + "appservice": [ + "azure.cli.command_modules.appservice" ], "aro": [ "azure.cli.command_modules.aro" @@ -23,40 +40,38 @@ "backup": [ "azure.cli.command_modules.backup" ], - "batchai": [ - "azure.cli.command_modules.batchai" - ], "batch": [ "azure.cli.command_modules.batch" ], - "appservice": [ - "azure.cli.command_modules.appservice" - ], - "webapp": [ - "azure.cli.command_modules.appservice", - "azure.cli.command_modules.serviceconnector" - ], - "functionapp": [ - "azure.cli.command_modules.appservice", - "azure.cli.command_modules.serviceconnector" + "batchai": [ + "azure.cli.command_modules.batchai" ], - "staticwebapp": [ - "azure.cli.command_modules.appservice" + "bicep": [ + "azure.cli.command_modules.resource" ], - "logicapp": [ - "azure.cli.command_modules.appservice" + "billing": [ + "azure.cli.command_modules.billing" ], "bot": [ "azure.cli.command_modules.botservice" ], + "cache": [ + "azure.cli.command_modules.configure" + ], + "capacity": [ + "azure.cli.command_modules.vm" + ], + "cdn": [ + "azure.cli.command_modules.cdn" + ], "cloud": [ "azure.cli.command_modules.cloud" ], "cognitiveservices": [ "azure.cli.command_modules.cognitiveservices" ], - "billing": [ - "azure.cli.command_modules.billing" + "compute-fleet": [ + "azure.cli.command_modules.computefleet" ], "compute-recommender": [ "azure.cli.command_modules.compute_recommender" @@ -64,66 +79,94 @@ "config": [ "azure.cli.command_modules.config" ], - "compute-fleet": [ - "azure.cli.command_modules.computefleet" - ], "configure": [ "azure.cli.command_modules.configure" ], - "cache": [ - "azure.cli.command_modules.configure" - ], - "container": [ - "azure.cli.command_modules.container" + "connection": [ + "azure.cli.command_modules.serviceconnector" ], "consumption": [ "azure.cli.command_modules.consumption" ], - "aks": [ - "azure.cli.command_modules.acs", + "container": [ + "azure.cli.command_modules.container" + ], + "containerapp": [ + "azure.cli.command_modules.containerapp", "azure.cli.command_modules.serviceconnector" ], "cosmosdb": [ "azure.cli.command_modules.cosmosdb" ], - "managed-cassandra": [ - "azure.cli.command_modules.cosmosdb" - ], - "dls": [ - "azure.cli.command_modules.dls" + "data-boundary": [ + "azure.cli.command_modules.resource" ], "databoxedge": [ "azure.cli.command_modules.databoxedge" ], + "demo": [ + "azure.cli.command_modules.util" + ], + "deployment": [ + "azure.cli.command_modules.resource" + ], + "deployment-scripts": [ + "azure.cli.command_modules.resource" + ], + "disk": [ + "azure.cli.command_modules.vm" + ], + "disk-access": [ + "azure.cli.command_modules.vm" + ], + "disk-encryption-set": [ + "azure.cli.command_modules.vm" + ], + "dls": [ + "azure.cli.command_modules.dls" + ], "dms": [ "azure.cli.command_modules.dms" ], "eventgrid": [ "azure.cli.command_modules.eventgrid" ], + "eventhubs": [ + "azure.cli.command_modules.eventhubs" + ], "extension": [ "azure.cli.command_modules.extension" ], - "feedback": [ - "azure.cli.command_modules.feedback" + "feature": [ + "azure.cli.command_modules.resource" ], - "survey": [ + "feedback": [ "azure.cli.command_modules.feedback" ], "find": [ "azure.cli.command_modules.find" ], + "functionapp": [ + "azure.cli.command_modules.appservice", + "azure.cli.command_modules.serviceconnector" + ], + "group": [ + "azure.cli.command_modules.resource" + ], "hdinsight": [ "azure.cli.command_modules.hdinsight" ], - "eventhubs": [ - "azure.cli.command_modules.eventhubs" + "identity": [ + "azure.cli.command_modules.identity" + ], + "image": [ + "azure.cli.command_modules.vm" ], "interactive": [ "azure.cli.command_modules.interactive" ], - "identity": [ - "azure.cli.command_modules.identity" + "iot": [ + "azure.cli.command_modules.iot" ], "keyvault": [ "azure.cli.command_modules.keyvault" @@ -131,42 +174,11 @@ "lab": [ "azure.cli.command_modules.lab" ], - "iot": [ - "azure.cli.command_modules.iot" - ], - "containerapp": [ - "azure.cli.command_modules.containerapp", - "azure.cli.command_modules.serviceconnector" - ], - "maps": [ - "azure.cli.command_modules.maps" - ], - "managedservices": [ - "azure.cli.command_modules.managedservices" - ], - "term": [ - "azure.cli.command_modules.marketplaceordering" - ], - "afd": [ - "azure.cli.command_modules.cdn" - ], - "cdn": [ - "azure.cli.command_modules.cdn" - ], - "netappfiles": [ - "azure.cli.command_modules.netappfiles" - ], - "mysql": [ - "azure.cli.command_modules.mysql", - "azure.cli.command_modules.rdbms" - ], - "policy": [ - "azure.cli.command_modules.policyinsights", + "lock": [ "azure.cli.command_modules.resource" ], - "network": [ - "azure.cli.command_modules.privatedns", - "azure.cli.command_modules.network" + "logicapp": [ + "azure.cli.command_modules.appservice" ], "login": [ "azure.cli.command_modules.profile" @@ -174,81 +186,80 @@ "logout": [ "azure.cli.command_modules.profile" ], - "self-test": [ - "azure.cli.command_modules.profile" + "managed-cassandra": [ + "azure.cli.command_modules.cosmosdb" ], - "account": [ - "azure.cli.command_modules.profile", + "managedapp": [ "azure.cli.command_modules.resource" ], - "postgres": [ - "azure.cli.command_modules.postgresql" + "managedservices": [ + "azure.cli.command_modules.managedservices" ], - "redis": [ - "azure.cli.command_modules.redis" + "maps": [ + "azure.cli.command_modules.maps" ], "mariadb": [ "azure.cli.command_modules.rdbms" ], - "relay": [ - "azure.cli.command_modules.relay" - ], - "role": [ - "azure.cli.command_modules.role" + "monitor": [ + "azure.cli.command_modules.monitor" ], - "ad": [ - "azure.cli.command_modules.role" + "mysql": [ + "azure.cli.command_modules.mysql", + "azure.cli.command_modules.rdbms" ], - "search": [ - "azure.cli.command_modules.search" + "netappfiles": [ + "azure.cli.command_modules.netappfiles" ], - "security": [ - "azure.cli.command_modules.security" + "network": [ + "azure.cli.command_modules.network", + "azure.cli.command_modules.privatedns" ], - "data-boundary": [ + "policy": [ + "azure.cli.command_modules.policyinsights", "azure.cli.command_modules.resource" ], - "group": [ - "azure.cli.command_modules.resource" + "postgres": [ + "azure.cli.command_modules.postgresql" ], - "resource": [ - "azure.cli.command_modules.resource" + "ppg": [ + "azure.cli.command_modules.vm" ], - "provider": [ + "private-link": [ "azure.cli.command_modules.resource" ], - "feature": [ + "provider": [ "azure.cli.command_modules.resource" ], - "tag": [ - "azure.cli.command_modules.resource" + "redis": [ + "azure.cli.command_modules.redis" ], - "deployment": [ - "azure.cli.command_modules.resource" + "relay": [ + "azure.cli.command_modules.relay" ], - "deployment-scripts": [ + "resource": [ "azure.cli.command_modules.resource" ], - "ts": [ + "resourcemanagement": [ "azure.cli.command_modules.resource" ], - "stack": [ - "azure.cli.command_modules.resource" + "rest": [ + "azure.cli.command_modules.util" ], - "lock": [ - "azure.cli.command_modules.resource" + "restore-point": [ + "azure.cli.command_modules.vm" ], - "managedapp": [ - "azure.cli.command_modules.resource" + "role": [ + "azure.cli.command_modules.role" ], - "bicep": [ - "azure.cli.command_modules.resource" + "search": [ + "azure.cli.command_modules.search" ], - "resourcemanagement": [ - "azure.cli.command_modules.resource" + "security": [ + "azure.cli.command_modules.security" ], - "private-link": [ - "azure.cli.command_modules.resource" + "self-test": [ + "azure.cli.command_modules.profile" ], "servicebus": [ "azure.cli.command_modules.servicebus" @@ -256,72 +267,61 @@ "sf": [ "azure.cli.command_modules.servicefabric" ], + "sig": [ + "azure.cli.command_modules.vm" + ], "signalr": [ "azure.cli.command_modules.signalr" ], + "snapshot": [ + "azure.cli.command_modules.vm" + ], "sql": [ "azure.cli.command_modules.sql", "azure.cli.command_modules.sqlvm" ], + "sshkey": [ + "azure.cli.command_modules.vm" + ], + "stack": [ + "azure.cli.command_modules.resource" + ], + "staticwebapp": [ + "azure.cli.command_modules.appservice" + ], "storage": [ "azure.cli.command_modules.storage" ], + "survey": [ + "azure.cli.command_modules.feedback" + ], "synapse": [ "azure.cli.command_modules.synapse" ], - "rest": [ - "azure.cli.command_modules.util" + "tag": [ + "azure.cli.command_modules.resource" ], - "version": [ - "azure.cli.command_modules.util" + "term": [ + "azure.cli.command_modules.marketplaceordering" + ], + "ts": [ + "azure.cli.command_modules.resource" ], "upgrade": [ "azure.cli.command_modules.util" ], - "demo": [ + "version": [ "azure.cli.command_modules.util" ], - "connection": [ - "azure.cli.command_modules.serviceconnector" - ], - "capacity": [ - "azure.cli.command_modules.vm" - ], - "disk": [ - "azure.cli.command_modules.vm" - ], - "disk-access": [ - "azure.cli.command_modules.vm" - ], - "disk-encryption-set": [ - "azure.cli.command_modules.vm" - ], - "image": [ - "azure.cli.command_modules.vm" - ], - "ppg": [ - "azure.cli.command_modules.vm" - ], - "restore-point": [ - "azure.cli.command_modules.vm" - ], - "sig": [ - "azure.cli.command_modules.vm" - ], - "snapshot": [ - "azure.cli.command_modules.vm" - ], "vm": [ "azure.cli.command_modules.vm" ], "vmss": [ "azure.cli.command_modules.vm" ], - "sshkey": [ - "azure.cli.command_modules.vm" - ], - "monitor": [ - "azure.cli.command_modules.monitor" + "webapp": [ + "azure.cli.command_modules.appservice", + "azure.cli.command_modules.serviceconnector" ] } } diff --git a/src/azure-cli-core/azure/cli/core/helpIndex.latest.json b/src/azure-cli-core/azure/cli/core/helpIndex.latest.json index ca9c215d7a8..616fcee61d5 100644 --- a/src/azure-cli-core/azure/cli/core/helpIndex.latest.json +++ b/src/azure-cli-core/azure/cli/core/helpIndex.latest.json @@ -3,76 +3,80 @@ "cloudProfile": "latest", "helpIndex": { "groups": { - "aks": { - "summary": "Azure Kubernetes Service.", - "tags": "" - }, - "network": { - "summary": "Manage Azure Network resources.", + "account": { + "summary": "Manage Azure subscription information.", "tags": "" }, - "monitor": { - "summary": "Manage the Azure Monitor Service.", + "acr": { + "summary": "Manage private registries with Azure Container Registries.", "tags": "" }, - "containerapp": { - "summary": "Manage Azure Container Apps.", + "ad": { + "summary": "Manage Microsoft Entra ID (formerly known as Azure Active Directory, Azure AD, AAD) entities needed for Azure role-based access control (Azure RBAC) through Microsoft Graph API.", "tags": "" }, "advisor": { "summary": "Manage Azure Advisor.", "tags": "" }, - "ams": { - "summary": "Manage Azure Media Services resources.", + "afd": { + "summary": "Manage Azure Front Door Standard/Premium.", "tags": "" }, - "appconfig": { - "summary": "Manage App Configurations.", + "aks": { + "summary": "Azure Kubernetes Service.", "tags": "" }, - "acr": { - "summary": "Manage private registries with Azure Container Registries.", + "ams": { + "summary": "Manage Azure Media Services resources.", "tags": "" }, "apim": { "summary": "Manage Azure API Management services.", "tags": "" }, - "backup": { - "summary": "Manage Azure Backups.", + "appconfig": { + "summary": "Manage App Configurations.", + "tags": "" + }, + "appservice": { + "summary": "Manage Appservice.", "tags": "" }, "aro": { "summary": "Manage Azure Red Hat OpenShift clusters.", "tags": "" }, + "backup": { + "summary": "Manage Azure Backups.", + "tags": "" + }, "batch": { "summary": "Manage Azure Batch.", "tags": "" }, - "bot": { - "summary": "Manage Microsoft Azure Bot Service.", + "bicep": { + "summary": "Bicep CLI command group.", "tags": "" }, - "appservice": { - "summary": "Manage Appservice.", + "billing": { + "summary": "Manage Azure Billing.", "tags": "" }, - "webapp": { - "summary": "Manage web apps.", + "bot": { + "summary": "Manage Microsoft Azure Bot Service.", "tags": "" }, - "functionapp": { - "summary": "Manage function apps. To install the Azure Functions Core tools see https://github.com/Azure/azure-functions-core-tools.", + "cache": { + "summary": "Commands to manage CLI objects cached using the `--defer` argument.", "tags": "" }, - "staticwebapp": { - "summary": "Manage static apps.", + "capacity": { + "summary": "Manage capacity.", "tags": "" }, - "logicapp": { - "summary": "Manage logic apps.", + "cdn": { + "summary": "Manage Azure Content Delivery Networks (CDNs).", "tags": "" }, "cloud": { @@ -83,9 +87,9 @@ "summary": "Manage Azure Cognitive Services accounts.", "tags": "" }, - "billing": { - "summary": "Manage Azure Billing.", - "tags": "" + "compute-fleet": { + "summary": "Manage for Azure Compute Fleet.", + "tags": "[Preview]" }, "compute-recommender": { "summary": "Manage sku/zone/region recommender info for compute resources.", @@ -95,264 +99,260 @@ "summary": "Manage Azure CLI configuration.", "tags": "[Experimental]" }, - "compute-fleet": { - "summary": "Manage for Azure Compute Fleet.", - "tags": "[Preview]" - }, - "cache": { - "summary": "Commands to manage CLI objects cached using the `--defer` argument.", + "connection": { + "summary": "Commands to manage Service Connector local connections which allow local environment to connect Azure Resource. If you want to manage connection for compute service, please run 'az webapp/containerapp/spring connection'.", "tags": "" }, + "consumption": { + "summary": "Manage consumption of Azure resources.", + "tags": "[Preview]" + }, "container": { "summary": "Manage Azure Container Instances.", "tags": "" }, + "containerapp": { + "summary": "Manage Azure Container Apps.", + "tags": "" + }, "cosmosdb": { "summary": "Manage Azure Cosmos DB database accounts.", "tags": "" }, - "managed-cassandra": { - "summary": "Azure Managed Cassandra.", + "data-boundary": { + "summary": "Data boundary operations.", "tags": "" }, - "dls": { - "summary": "Manage Data Lake Store accounts and filesystems.", - "tags": "[Preview]" - }, "databoxedge": { "summary": "Manage device with databoxedge.", "tags": "" }, - "consumption": { - "summary": "Manage consumption of Azure resources.", - "tags": "[Preview]" + "deployment": { + "summary": "Manage Azure Resource Manager template deployment at subscription scope.", + "tags": "" }, - "dms": { - "summary": "Manage Azure Data Migration Service (classic) instances.", + "deployment-scripts": { + "summary": "Manage deployment scripts at subscription or resource group scope.", "tags": "" }, - "eventgrid": { - "summary": "Manage Azure Event Grid topics, domains, domain topics, system topics partner topics, event subscriptions, system topic event subscriptions and partner topic event subscriptions.", + "disk": { + "summary": "Manage Azure Managed Disks.", "tags": "" }, - "extension": { - "summary": "Manage and update CLI extensions.", + "disk-access": { + "summary": "Manage disk access resources.", "tags": "" }, - "hdinsight": { - "summary": "Manage HDInsight resources.", + "disk-encryption-set": { + "summary": "Disk Encryption Set resource.", "tags": "" }, - "identity": { - "summary": "Manage Managed Identity.", + "dls": { + "summary": "Manage Data Lake Store accounts and filesystems.", + "tags": "[Preview]" + }, + "dms": { + "summary": "Manage Azure Data Migration Service (classic) instances.", + "tags": "" + }, + "eventgrid": { + "summary": "Manage Azure Event Grid topics, domains, domain topics, system topics partner topics, event subscriptions, system topic event subscriptions and partner topic event subscriptions.", "tags": "" }, "eventhubs": { "summary": "Eventhubs.", "tags": "" }, - "keyvault": { - "summary": "Manage KeyVault keys, secrets, and certificates.", + "extension": { + "summary": "Manage and update CLI extensions.", "tags": "" }, - "managedservices": { - "summary": "Manage the registration assignments and definitions in Azure.", + "feature": { + "summary": "Manage resource provider features.", "tags": "" }, - "maps": { - "summary": "Manage Azure Maps.", + "functionapp": { + "summary": "Manage function apps. To install the Azure Functions Core tools see https://github.com/Azure/azure-functions-core-tools.", "tags": "" }, - "term": { - "summary": "Manage marketplace agreement with marketplaceordering.", - "tags": "[Experimental]" + "group": { + "summary": "Manage resource groups and template deployments.", + "tags": "" }, - "lab": { - "summary": "Manage azure devtest labs.", - "tags": "[Preview]" + "hdinsight": { + "summary": "Manage HDInsight resources.", + "tags": "" }, - "afd": { - "summary": "Manage Azure Front Door Standard/Premium.", + "identity": { + "summary": "Manage Managed Identity.", "tags": "" }, - "cdn": { - "summary": "Manage Azure Content Delivery Networks (CDNs).", + "image": { + "summary": "Manage custom virtual machine images.", "tags": "" }, "iot": { "summary": "Manage Internet of Things (IoT) assets.", "tags": "" }, - "netappfiles": { - "summary": "Manage Azure NetApp Files (ANF) Resources.", + "keyvault": { + "summary": "Manage KeyVault keys, secrets, and certificates.", "tags": "" }, - "mysql": { - "summary": "Manage Azure Database for MySQL servers.", + "lab": { + "summary": "Manage azure devtest labs.", + "tags": "[Preview]" + }, + "lock": { + "summary": "Manage Azure locks.", "tags": "" }, - "policy": { - "summary": "Manage resources defined and used by the Azure Policy service.", + "logicapp": { + "summary": "Manage logic apps.", "tags": "" }, - "account": { - "summary": "Manage Azure subscription information.", + "managed-cassandra": { + "summary": "Azure Managed Cassandra.", "tags": "" }, - "postgres": { - "summary": "Manage Azure Database for PostgreSQL.", + "managedapp": { + "summary": "Manage template solutions provided and maintained by Independent Software Vendors (ISVs).", "tags": "" }, - "redis": { - "summary": "Manage dedicated Redis caches for your Azure applications.", + "managedservices": { + "summary": "Manage the registration assignments and definitions in Azure.", "tags": "" }, - "relay": { - "summary": "Manage Azure Relay Service namespaces, WCF relays, hybrid connections, and rules.", + "maps": { + "summary": "Manage Azure Maps.", "tags": "" }, "mariadb": { "summary": "Manage Azure Database for MariaDB servers.", "tags": "" }, - "role": { - "summary": "Manage Azure role-based access control (Azure RBAC).", + "monitor": { + "summary": "Manage the Azure Monitor Service.", "tags": "" }, - "ad": { - "summary": "Manage Microsoft Entra ID (formerly known as Azure Active Directory, Azure AD, AAD) entities needed for Azure role-based access control (Azure RBAC) through Microsoft Graph API.", + "mysql": { + "summary": "Manage Azure Database for MySQL servers.", "tags": "" }, - "search": { - "summary": "Manage Search.", + "netappfiles": { + "summary": "Manage Azure NetApp Files (ANF) Resources.", "tags": "" }, - "security": { - "summary": "Manage your security posture with Microsoft Defender for Cloud.", + "network": { + "summary": "Manage Azure Network resources.", "tags": "" }, - "servicebus": { - "summary": "Servicebus.", + "policy": { + "summary": "Manage resources defined and used by the Azure Policy service.", "tags": "" }, - "data-boundary": { - "summary": "Data boundary operations.", + "postgres": { + "summary": "Manage Azure Database for PostgreSQL.", "tags": "" }, - "group": { - "summary": "Manage resource groups and template deployments.", + "ppg": { + "summary": "Manage Proximity Placement Groups.", "tags": "" }, - "resource": { - "summary": "Manage Azure resources.", + "private-link": { + "summary": "Private-link association CLI command group.", "tags": "" }, "provider": { "summary": "Manage resource providers.", "tags": "" }, - "feature": { - "summary": "Manage resource provider features.", - "tags": "" - }, - "tag": { - "summary": "Tag Management on a resource.", - "tags": "" - }, - "deployment": { - "summary": "Manage Azure Resource Manager template deployment at subscription scope.", + "redis": { + "summary": "Manage dedicated Redis caches for your Azure applications.", "tags": "" }, - "deployment-scripts": { - "summary": "Manage deployment scripts at subscription or resource group scope.", + "relay": { + "summary": "Manage Azure Relay Service namespaces, WCF relays, hybrid connections, and rules.", "tags": "" }, - "ts": { - "summary": "Manage template specs at subscription or resource group scope.", + "resource": { + "summary": "Manage Azure resources.", "tags": "" }, - "stack": { - "summary": "A deployment stack is a native Azure resource type that enables you to perform operations on a resource collection as an atomic unit.", + "resourcemanagement": { + "summary": "Resourcemanagement CLI command group.", "tags": "" }, - "lock": { - "summary": "Manage Azure locks.", + "restore-point": { + "summary": "Manage restore point with res.", "tags": "" }, - "managedapp": { - "summary": "Manage template solutions provided and maintained by Independent Software Vendors (ISVs).", + "role": { + "summary": "Manage Azure role-based access control (Azure RBAC).", "tags": "" }, - "bicep": { - "summary": "Bicep CLI command group.", + "search": { + "summary": "Manage Search.", "tags": "" }, - "resourcemanagement": { - "summary": "Resourcemanagement CLI command group.", + "security": { + "summary": "Manage your security posture with Microsoft Defender for Cloud.", "tags": "" }, - "private-link": { - "summary": "Private-link association CLI command group.", + "servicebus": { + "summary": "Servicebus.", "tags": "" }, "sf": { "summary": "Manage and administer Azure Service Fabric clusters.", "tags": "" }, - "signalr": { - "summary": "Manage Azure SignalR Service.", - "tags": "" - }, - "sql": { - "summary": "Manage Azure SQL Databases and Data Warehouses.", - "tags": "" - }, - "storage": { - "summary": "Manage Azure Cloud Storage resources.", + "sig": { + "summary": "Manage shared image gallery.", "tags": "" }, - "synapse": { - "summary": "Manage and operate Synapse Workspace, Spark Pool, SQL Pool.", + "signalr": { + "summary": "Manage Azure SignalR Service.", "tags": "" }, - "connection": { - "summary": "Commands to manage Service Connector local connections which allow local environment to connect Azure Resource. If you want to manage connection for compute service, please run 'az webapp/containerapp/spring connection'.", + "snapshot": { + "summary": "Manage point-in-time copies of managed disks, native blobs, or other snapshots.", "tags": "" }, - "capacity": { - "summary": "Manage capacity.", + "sql": { + "summary": "Manage Azure SQL Databases and Data Warehouses.", "tags": "" }, - "disk": { - "summary": "Manage Azure Managed Disks.", + "sshkey": { + "summary": "Manage ssh public key with vm.", "tags": "" }, - "disk-access": { - "summary": "Manage disk access resources.", + "stack": { + "summary": "A deployment stack is a native Azure resource type that enables you to perform operations on a resource collection as an atomic unit.", "tags": "" }, - "disk-encryption-set": { - "summary": "Disk Encryption Set resource.", + "staticwebapp": { + "summary": "Manage static apps.", "tags": "" }, - "image": { - "summary": "Manage custom virtual machine images.", + "storage": { + "summary": "Manage Azure Cloud Storage resources.", "tags": "" }, - "ppg": { - "summary": "Manage Proximity Placement Groups.", + "synapse": { + "summary": "Manage and operate Synapse Workspace, Spark Pool, SQL Pool.", "tags": "" }, - "restore-point": { - "summary": "Manage restore point with res.", + "tag": { + "summary": "Tag Management on a resource.", "tags": "" }, - "sig": { - "summary": "Manage shared image gallery.", - "tags": "" + "term": { + "summary": "Manage marketplace agreement with marketplaceordering.", + "tags": "[Experimental]" }, - "snapshot": { - "summary": "Manage point-in-time copies of managed disks, native blobs, or other snapshots.", + "ts": { + "summary": "Manage template specs at subscription or resource group scope.", "tags": "" }, "vm": { @@ -363,8 +363,8 @@ "summary": "Manage groupings of virtual machines in an Azure Virtual Machine Scale Set (VMSS).", "tags": "" }, - "sshkey": { - "summary": "Manage ssh public key with vm.", + "webapp": { + "summary": "Manage web apps.", "tags": "" } }, @@ -377,10 +377,6 @@ "summary": "Send feedback to the Azure CLI Team.", "tags": "" }, - "survey": { - "summary": "Take Azure CLI survey.", - "tags": "" - }, "find": { "summary": "I'm an AI robot, my advice is based on our Azure documentation as well as the usage patterns of Azure CLI and Azure ARM users. Using me improves Azure products and documentation.", "tags": "" @@ -401,13 +397,17 @@ "summary": "Invoke a custom request.", "tags": "" }, - "version": { - "summary": "Show the versions of Azure CLI modules and extensions in JSON format by default or format configured by --output.", + "survey": { + "summary": "Take Azure CLI survey.", "tags": "" }, "upgrade": { "summary": "Upgrade Azure CLI and extensions.", "tags": "[Preview]" + }, + "version": { + "summary": "Show the versions of Azure CLI modules and extensions in JSON format by default or format configured by --output.", + "tags": "" } } } From 0bc2e6f18a1d72920818eb3c9450128dfbd68b3d Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 19 Mar 2026 17:21:28 +1100 Subject: [PATCH 16/26] feature: add pipeline step for checking generated indices --- azure-pipelines.yml | 17 +++++++++++++++++ .../azure/cli/core/commandIndex.latest.json | 4 ++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 20dd1dbd762..d76f5531208 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -1037,6 +1037,23 @@ jobs: docker pull ${DISTRO_BASE_IMAGE} docker run --rm -e DISTRO=${DISTRO} -e CLI_VERSION=$CLI_VERSION -v $SYSTEM_ARTIFACTSDIRECTORY/debian:/mnt/artifacts -v $(pwd):/azure-cli ${DISTRO_BASE_IMAGE} /bin/bash "/azure-cli/scripts/release/debian/test_deb_in_docker.sh" +- job: VerifyLatestIndices + displayName: "Verify latest index assets" + timeoutInMinutes: 20 + pool: + name: ${{ variables.ubuntu_pool }} + steps: + - task: UsePythonVersion@0 + displayName: 'Use Python 3.13' + inputs: + versionSpec: 3.13 + - template: .azure-pipelines/templates/azdev_setup.yml + - bash: | + set -ev + . env/bin/activate + python scripts/generate_latest_indices.py verify + displayName: 'Verify generated latest indices' + - job: CheckStyle displayName: "Check CLI Style" timeoutInMinutes: 120 diff --git a/src/azure-cli-core/azure/cli/core/commandIndex.latest.json b/src/azure-cli-core/azure/cli/core/commandIndex.latest.json index a7242471e04..51a8da53a2d 100644 --- a/src/azure-cli-core/azure/cli/core/commandIndex.latest.json +++ b/src/azure-cli-core/azure/cli/core/commandIndex.latest.json @@ -19,8 +19,8 @@ "azure.cli.command_modules.cdn" ], "aks": [ - "azure.cli.command_modules.serviceconnector", - "azure.cli.command_modules.acs" + "azure.cli.command_modules.acs", + "azure.cli.command_modules.serviceconnector" ], "ams": [ "azure.cli.command_modules.ams" From 858ed2cd380b241e47b4397b0834564392d446f9 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 20 Mar 2026 08:47:59 +1100 Subject: [PATCH 17/26] nit: change latest.json so it fails --- src/azure-cli-core/azure/cli/core/commandIndex.latest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commandIndex.latest.json b/src/azure-cli-core/azure/cli/core/commandIndex.latest.json index 51a8da53a2d..a7242471e04 100644 --- a/src/azure-cli-core/azure/cli/core/commandIndex.latest.json +++ b/src/azure-cli-core/azure/cli/core/commandIndex.latest.json @@ -19,8 +19,8 @@ "azure.cli.command_modules.cdn" ], "aks": [ - "azure.cli.command_modules.acs", - "azure.cli.command_modules.serviceconnector" + "azure.cli.command_modules.serviceconnector", + "azure.cli.command_modules.acs" ], "ams": [ "azure.cli.command_modules.ams" From e3a98a490a025800b624cd7b6ba7c7a6b09093c3 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 20 Mar 2026 10:56:30 +1100 Subject: [PATCH 18/26] nit: re-generate commandIndex.latest.json --- src/azure-cli-core/azure/cli/core/commandIndex.latest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/commandIndex.latest.json b/src/azure-cli-core/azure/cli/core/commandIndex.latest.json index a7242471e04..51a8da53a2d 100644 --- a/src/azure-cli-core/azure/cli/core/commandIndex.latest.json +++ b/src/azure-cli-core/azure/cli/core/commandIndex.latest.json @@ -19,8 +19,8 @@ "azure.cli.command_modules.cdn" ], "aks": [ - "azure.cli.command_modules.serviceconnector", - "azure.cli.command_modules.acs" + "azure.cli.command_modules.acs", + "azure.cli.command_modules.serviceconnector" ], "ams": [ "azure.cli.command_modules.ams" From 9149f98644f004561a6da764b34952700d22ccdc Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 23 Mar 2026 09:10:20 +1100 Subject: [PATCH 19/26] fix: change print statement to be env agnostic --- scripts/generate_latest_indices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/generate_latest_indices.py b/scripts/generate_latest_indices.py index e7cde4c8ec3..927df74fee8 100644 --- a/scripts/generate_latest_indices.py +++ b/scripts/generate_latest_indices.py @@ -279,7 +279,7 @@ def _run_verify(command_text, help_text): for path in mismatched: print(f' - {path.relative_to(REPO_ROOT)}') print('Run:') - print(' env\\Scripts\\python.exe scripts\\generate_latest_indices.py generate') + print(' python scripts/generate_latest_indices.py generate') return 1 print('Verified: latest index files are up-to-date.') From 87e3c00ba2be54bfbe35dfd59f7a2b5dc997b021 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 23 Mar 2026 11:05:27 +1100 Subject: [PATCH 20/26] fix: address copilot suggestions around casing --- src/azure-cli-core/azure/cli/core/__init__.py | 23 +++++++++--- .../core/tests/test_command_registration.py | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 05283e12f17..13e10f41514 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -43,6 +43,18 @@ MAX_WORKER_THREAD_COUNT = 4 +def _get_top_level_command(args): + """Return normalized top-level command token or None when unavailable.""" + if not args: + return None + + top_command = args[0] + if not top_command or top_command.startswith('-'): + return None + + return top_command.lower() + + def _configure_knack(): """Override consts defined in knack to make them Azure CLI-specific.""" @@ -536,9 +548,9 @@ def _get_extension_suppressions(mod_loaders): "The index may be outdated.", raw_cmd) if self._is_latest_non_completion_invocation(command_index, args): - top_command = args[0] + top_command = _get_top_level_command(args) packaged_core_index = command_index.get_packaged_core_index() or {} - if top_command != 'help' and top_command not in packaged_core_index: + if top_command and top_command != 'help' and top_command not in packaged_core_index: logger.debug("Top-level command '%s' is not in packaged core index. " "Skipping full core module reload.", top_command) return self.command_table @@ -1047,7 +1059,7 @@ def get(self, args): :return: a tuple containing a list of modules and a list of extensions. """ - top_command = args[0] if args else None + top_command = _get_top_level_command(args) if self.cloud_profile == 'latest': latest_result = self._resolve_latest_index_lookup(args, top_command) @@ -1074,14 +1086,13 @@ def _lookup_command_in_index(self, index, args, force_load_all_extensions=False) # Make sure the top-level command is provided, like `az version`. # Skip command index for `az` or `az --help`. - if not args or args[0].startswith('-'): + top_command = _get_top_level_command(args) + if not top_command: # For top-level completion (az [tab]) if not args and self.cli_ctx.data.get('completer_active'): return self._get_top_level_completion_commands(index=index) return None - # Get the top-level command, like `network` in `network vnet create -h` - top_command = args[0].lower() index = index or {} # Check the command index for (command: [module]) mapping, like diff --git a/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py b/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py index b55e2154197..b8361ecf30b 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_command_registration.py @@ -514,6 +514,43 @@ def test_command_index_loads_all_extensions_when_overlay_missing(self): self.assertIn('azext_hello1', EXTENSION_INDEX[CommandIndex._COMMAND_INDEX]['hello']) self.assertIn('azext_hello2', EXTENSION_INDEX[CommandIndex._COMMAND_INDEX]['hello']) + @mock.patch('importlib.import_module', _mock_import_lib) + @mock.patch('pkgutil.iter_modules', _mock_iter_modules) + @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) + @mock.patch('azure.cli.core.extension.get_extension_modname', _mock_get_extension_modname) + @mock.patch('azure.cli.core.extension.get_extensions', _mock_get_extensions) + def test_command_index_latest_uppercase_help_triggers_full_core_reload(self): + from azure.cli.core._session import INDEX, EXTENSION_INDEX + from azure.cli.core import CommandIndex, __version__ + + cli = DummyCli() + loader = cli.commands_loader + + INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + INDEX[CommandIndex._COMMAND_INDEX] = {} + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_VERSION] = "" + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = "" + EXTENSION_INDEX[CommandIndex._COMMAND_INDEX] = {} + + packaged_index = { + CommandIndex._COMMAND_INDEX_VERSION: __version__, + CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: cli.cloud.profile, + CommandIndex._COMMAND_INDEX: { + 'hello': ['azure.cli.command_modules.hello'], + 'extra': ['azure.cli.command_modules.extra'] + } + } + + with mock.patch.object(CommandIndex, '_load_packaged_command_index', return_value=packaged_index): + cmd_tbl = loader.load_command_table(["HELP"]) + + # HELP should be treated the same as help and must not short-circuit core module reload. + self.assertIn('hello mod-only', cmd_tbl) + self.assertIn('extra final', cmd_tbl) + self.assertIn('hello', INDEX[CommandIndex._COMMAND_INDEX]) + self.assertIn('extra', INDEX[CommandIndex._COMMAND_INDEX]) + @mock.patch('importlib.import_module', _mock_import_lib) @mock.patch('pkgutil.iter_modules', _mock_iter_modules) @mock.patch('azure.cli.core.commands._load_command_loader', _mock_load_command_loader) From 52dec9559c2779dfb5c2d7a3f8840ee23f5c6aac Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 23 Mar 2026 13:52:47 +1100 Subject: [PATCH 21/26] fix: if extensionHelpIndex is invalid, reload only extensions for refresh, not core modules also --- src/azure-cli-core/azure/cli/core/__init__.py | 10 +++++++ .../azure/cli/core/commands/__init__.py | 7 +++++ .../azure/cli/core/tests/test_help.py | 26 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 13e10f41514..f4bc2a2e218 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -1186,6 +1186,16 @@ def get_help_index(self): return self._get_help_index_cached_local() + def needs_latest_extension_help_overlay_refresh(self): + """Return True when latest-profile top-level help should refresh extension help overlay.""" + if self.cloud_profile != 'latest': + return False + + if self._is_extension_help_index_valid(): + return False + + return self._has_non_always_loaded_extensions() + def set_help_index(self, help_data): """Set the help index data. 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 9ab97f29b6c..20c46dfc463 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -745,6 +745,13 @@ def _try_show_cached_help(self, args): command_index = CommandIndex(self.cli_ctx) help_index = command_index.get_help_index() + if not help_index and command_index.needs_latest_extension_help_overlay_refresh(): + logger.debug("Top-level cached help is unavailable on latest profile. " + "Refreshing extension help overlay without full core module load.") + # Unknown top-level command forces extension-only load path on latest profile. + self.commands_loader.load_command_table(['__refresh_extension_help_overlay__']) + 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) 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 ba9505c1cb4..980117d7145 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 @@ -743,6 +743,32 @@ def test_show_cached_help_output(self): finally: sys.stdout = sys.__stdout__ + def test_try_show_cached_help_refreshes_latest_extension_overlay(self): + """Test top-level cached help retries after refreshing latest extension help overlay.""" + from azure.cli.core import CommandIndex + + invoker = self.test_cli.invocation_cls( + cli_ctx=self.test_cli, + commands_loader_cls=self.test_cli.commands_loader_cls, + parser_cls=self.test_cli.parser_cls, + help_cls=self.test_cli.help_cls) + self.test_cli.invocation = invoker + + refreshed_help_data = { + 'groups': {'vm': {'summary': 'Manage VMs.', 'tags': ''}}, + 'commands': {'version': {'summary': 'Show version.', 'tags': ''}} + } + + with mock.patch.object(CommandIndex, 'get_help_index', side_effect=[None, refreshed_help_data]), \ + mock.patch.object(CommandIndex, 'needs_latest_extension_help_overlay_refresh', return_value=True), \ + mock.patch.object(invoker.commands_loader, 'load_command_table') as mock_load_cmd_table, \ + mock.patch.object(invoker.help, 'show_cached_help') as mock_show_cached_help: + result = invoker._try_show_cached_help(['--help', '--debug']) + + self.assertIsNotNone(result) + mock_load_cmd_table.assert_called_once_with(['__refresh_extension_help_overlay__']) + mock_show_cached_help.assert_called_once_with(refreshed_help_data, ['--help', '--debug']) + # 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: From 726da365a7cbfc8e37c3b3acd4b8dae24679e8d4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 23 Mar 2026 14:13:11 +1100 Subject: [PATCH 22/26] fix: init string before show cached help --- .../azure/cli/core/commands/__init__.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 20c46dfc463..ee90d520139 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -748,9 +748,15 @@ def _try_show_cached_help(self, args): if not help_index and command_index.needs_latest_extension_help_overlay_refresh(): logger.debug("Top-level cached help is unavailable on latest profile. " "Refreshing extension help overlay without full core module load.") - # Unknown top-level command forces extension-only load path on latest profile. - self.commands_loader.load_command_table(['__refresh_extension_help_overlay__']) - help_index = command_index.get_help_index() + try: + if self.cli_ctx.invocation.data.get('command_string') is None: + self.cli_ctx.invocation.data['command_string'] = '' + # Unknown top-level command forces extension-only load path on latest profile. + self.commands_loader.load_command_table(['__refresh_extension_help_overlay__']) + help_index = command_index.get_help_index() + except Exception as ex: # pylint: disable=broad-except + # Keep cached-help refresh best-effort; normal invocation flow can still continue. + logger.debug("Failed to refresh latest extension help overlay: %s", ex) if help_index: # Display cached help using the help system From eeb54859d32f4c11d9970e083b474a0da5c8abd3 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 23 Mar 2026 17:08:00 +1100 Subject: [PATCH 23/26] feature: add EOL settings to gitattributes --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitattributes b/.gitattributes index 391370e95ee..8ae308ad2a7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,3 +15,7 @@ build_scripts/windows/scripts/az eol=lf # sh scripts should be LF *.sh eol=lf + +# Generated latest index assets should always use LF to avoid cross-platform churn +src/azure-cli-core/azure/cli/core/commandIndex.latest.json text eol=lf +src/azure-cli-core/azure/cli/core/helpIndex.latest.json text eol=lf From 4a45bef9538865802bb911d96087cd3e4de4cc69 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 25 Mar 2026 10:15:49 +1100 Subject: [PATCH 24/26] fix: change wording on debug msg --- src/azure-cli-core/azure/cli/core/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index f4bc2a2e218..15716d9e1bb 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -1170,7 +1170,7 @@ def _get_help_index_latest(self): self._clear_extension_help_overlay_cache() if self._has_non_always_loaded_extensions(): - logger.debug("Extension help overlay unavailable on latest profile. Triggering refresh via full load.") + logger.debug("Extension help overlay unavailable on latest profile. Help index will be refreshed when overlay becomes available.") return None logger.debug("Using packaged help index with %d entries", len(packaged_help_index)) From 78716f28af7ab8a426e8a557fde038436814e0b1 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 25 Mar 2026 10:23:01 +1100 Subject: [PATCH 25/26] fix: extract sentinal value to string --- src/azure-cli-core/azure/cli/core/__init__.py | 2 ++ src/azure-cli-core/azure/cli/core/commands/__init__.py | 4 ++-- src/azure-cli-core/azure/cli/core/tests/test_help.py | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 15716d9e1bb..fc4dcae82c1 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -31,6 +31,8 @@ EVENT_FAILED_EXTENSION_LOAD = 'MainLoader.OnFailedExtensionLoad' # Marker used by CommandIndex.get() to signal top-level tab completion optimization TOP_LEVEL_COMPLETION_MARKER = '__top_level_completion__' +# Internal sentinel used to trigger latest-profile extension help overlay refresh path. +REFRESH_EXTENSION_HELP_OVERLAY_SENTINEL = '__refresh_extension_help_overlay__' # [Reserved, in case of future usage] # Modules that will always be loaded. They don't expose commands but hook into CLI core. 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 ee90d520139..a9dc9cfbc43 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -741,7 +741,7 @@ def _try_show_cached_help(self, args): Returns CommandResultItem if cached help was shown, None otherwise. """ - from azure.cli.core import CommandIndex + from azure.cli.core import CommandIndex, REFRESH_EXTENSION_HELP_OVERLAY_SENTINEL command_index = CommandIndex(self.cli_ctx) help_index = command_index.get_help_index() @@ -752,7 +752,7 @@ def _try_show_cached_help(self, args): if self.cli_ctx.invocation.data.get('command_string') is None: self.cli_ctx.invocation.data['command_string'] = '' # Unknown top-level command forces extension-only load path on latest profile. - self.commands_loader.load_command_table(['__refresh_extension_help_overlay__']) + self.commands_loader.load_command_table([REFRESH_EXTENSION_HELP_OVERLAY_SENTINEL]) help_index = command_index.get_help_index() except Exception as ex: # pylint: disable=broad-except # Keep cached-help refresh best-effort; normal invocation flow can still continue. 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 980117d7145..3ec55758e90 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 @@ -745,7 +745,7 @@ def test_show_cached_help_output(self): def test_try_show_cached_help_refreshes_latest_extension_overlay(self): """Test top-level cached help retries after refreshing latest extension help overlay.""" - from azure.cli.core import CommandIndex + from azure.cli.core import CommandIndex, REFRESH_EXTENSION_HELP_OVERLAY_SENTINEL invoker = self.test_cli.invocation_cls( cli_ctx=self.test_cli, @@ -766,7 +766,7 @@ def test_try_show_cached_help_refreshes_latest_extension_overlay(self): result = invoker._try_show_cached_help(['--help', '--debug']) self.assertIsNotNone(result) - mock_load_cmd_table.assert_called_once_with(['__refresh_extension_help_overlay__']) + mock_load_cmd_table.assert_called_once_with([REFRESH_EXTENSION_HELP_OVERLAY_SENTINEL]) mock_show_cached_help.assert_called_once_with(refreshed_help_data, ['--help', '--debug']) # create a temporary file in the temp dir. Return the path of the file. From 234b7d6b3062ea4b65c7445baf76a7cb1eb32a62 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 25 Mar 2026 10:25:21 +1100 Subject: [PATCH 26/26] fix: change mock comment --- src/azure-cli-core/azure/cli/core/mock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/azure-cli-core/azure/cli/core/mock.py b/src/azure-cli-core/azure/cli/core/mock.py index 89c80d3d372..9bf6ca2032c 100644 --- a/src/azure-cli-core/azure/cli/core/mock.py +++ b/src/azure-cli-core/azure/cli/core/mock.py @@ -32,7 +32,7 @@ def __init__(self, commands_loader_cls=None, random_config_dir=False, **kwargs): self.env_patch = patch.dict(os.environ, {'AZURE_CONFIG_DIR': config_dir}) self.env_patch.start() - # Always copy command index to avoid initializing it again + # Always copy CLI index/cache files (command, extension, help) to avoid initializing them again files_to_copy = ['commandIndex.json', 'extensionIndex.json', 'helpIndex.json', 'extensionHelpIndex.json'] # In recording mode, copy login credentials from global config dir to the dummy config dir if os.getenv(ENV_VAR_TEST_LIVE, '').lower() == 'true':