Skip to content

Commit 9263570

Browse files
feature: adjust helpindex logic to draw from overlay for extensions
1 parent 46b9a6f commit 9263570

4 files changed

Lines changed: 170 additions & 29 deletions

File tree

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

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def __init__(self, **kwargs):
7373
register_ids_argument, register_global_subscription_argument, register_global_policy_argument)
7474
from azure.cli.core.cloud import get_active_cloud
7575
from azure.cli.core.commands.transform import register_global_transforms
76-
from azure.cli.core._session import ACCOUNT, CONFIG, SESSION, INDEX, EXTENSION_INDEX, HELP_INDEX, VERSIONS
76+
from azure.cli.core._session import ACCOUNT, CONFIG, SESSION, INDEX, EXTENSION_INDEX, HELP_INDEX, EXTENSION_HELP_INDEX, VERSIONS
7777
from azure.cli.core.util import handle_version_update
7878

7979
from knack.util import ensure_dir
@@ -92,6 +92,7 @@ def __init__(self, **kwargs):
9292
INDEX.load(os.path.join(azure_folder, 'commandIndex.json'))
9393
EXTENSION_INDEX.load(os.path.join(azure_folder, 'extensionIndex.json'))
9494
HELP_INDEX.load(os.path.join(azure_folder, 'helpIndex.json'))
95+
EXTENSION_HELP_INDEX.load(os.path.join(azure_folder, 'extensionHelpIndex.json'))
9596
VERSIONS.load(os.path.join(azure_folder, 'versionCheck.json'))
9697
handle_version_update()
9798

@@ -744,10 +745,11 @@ def __init__(self, cli_ctx=None):
744745
745746
:param cli_ctx: Only needed when `get` or `update` is called.
746747
"""
747-
from azure.cli.core._session import INDEX, EXTENSION_INDEX, HELP_INDEX
748+
from azure.cli.core._session import INDEX, EXTENSION_INDEX, HELP_INDEX, EXTENSION_HELP_INDEX
748749
self.INDEX = INDEX
749750
self.EXTENSION_INDEX = EXTENSION_INDEX
750751
self.HELP_INDEX = HELP_INDEX
752+
self.EXTENSION_HELP_INDEX = EXTENSION_HELP_INDEX
751753
if cli_ctx:
752754
self.version = __version__
753755
self.cloud_profile = cli_ctx.cloud.profile
@@ -782,6 +784,13 @@ def _is_extension_index_valid(self):
782784
return (index_version and index_version == self.version and
783785
cloud_profile and cloud_profile == self.cloud_profile)
784786

787+
def _is_extension_help_index_valid(self):
788+
"""Check if the extension help index version and cloud profile are valid."""
789+
index_version = self.EXTENSION_HELP_INDEX.get(self._COMMAND_INDEX_VERSION)
790+
cloud_profile = self.EXTENSION_HELP_INDEX.get(self._COMMAND_INDEX_CLOUD_PROFILE)
791+
return (index_version and index_version == self.version and
792+
cloud_profile and cloud_profile == self.cloud_profile)
793+
785794
def _get_top_level_completion_commands(self, index=None):
786795
"""Get top-level command names for tab completion optimization.
787796
@@ -918,6 +927,34 @@ def _blend_command_indices(core_index, extension_index):
918927
blended[cmd].append(mod)
919928
return blended
920929

930+
@staticmethod
931+
def _blend_help_indices(base_help_index, extension_help_index):
932+
"""Blend packaged core help with extension-only help overlay."""
933+
blended = {
934+
'groups': dict((base_help_index or {}).get('groups') or {}),
935+
'commands': dict((base_help_index or {}).get('commands') or {})
936+
}
937+
ext_help_index = extension_help_index or {}
938+
for section in ('groups', 'commands'):
939+
blended_section = blended[section]
940+
for key, value in (ext_help_index.get(section) or {}).items():
941+
blended_section[key] = value
942+
return blended
943+
944+
@staticmethod
945+
def _build_extension_help_overlay(base_help_index, full_help_index):
946+
"""Build extension-only help overlay by diffing full help against packaged core help."""
947+
overlay = {'groups': {}, 'commands': {}}
948+
base_help_index = base_help_index or {}
949+
full_help_index = full_help_index or {}
950+
for section in ('groups', 'commands'):
951+
base_section = base_help_index.get(section) or {}
952+
full_section = full_help_index.get(section) or {}
953+
for key, value in full_section.items():
954+
if key not in base_section or base_section[key] != value:
955+
overlay[section][key] = value
956+
return overlay
957+
921958
def _get_blended_latest_index(self):
922959
"""Get effective index for latest profile by blending core and extension indices."""
923960
if self.cloud_profile != 'latest':
@@ -1095,20 +1132,39 @@ def get_help_index(self):
10951132
:return: Dictionary mapping top-level commands to their short summaries, or None if not available
10961133
"""
10971134
if self.cloud_profile == 'latest':
1098-
# Prefer local cache if available and valid, as it may include extension-specific help entries.
1099-
if self._is_index_valid():
1100-
help_index = self.HELP_INDEX.get(self._HELP_INDEX, {})
1101-
if not help_index:
1102-
help_index = self._migrate_legacy_help_index() or {}
1103-
if help_index:
1104-
logger.debug("Using cached local help index with %d entries", len(help_index))
1105-
return help_index
1106-
1135+
# Packaged help is the base for latest profile.
11071136
packaged_help_index = self._load_packaged_help_index()
1108-
if packaged_help_index:
1109-
logger.debug("Using packaged help index with %d entries", len(packaged_help_index))
1110-
return packaged_help_index
1111-
return None
1137+
if not packaged_help_index:
1138+
# Defensive fallback to local cache if packaged asset is unavailable.
1139+
if self._is_index_valid():
1140+
help_index = self.HELP_INDEX.get(self._HELP_INDEX, {})
1141+
if not help_index:
1142+
help_index = self._migrate_legacy_help_index() or {}
1143+
if help_index:
1144+
logger.debug("Using cached local help index with %d entries", len(help_index))
1145+
return help_index
1146+
return None
1147+
1148+
if self._is_extension_help_index_valid():
1149+
extension_help_index = self.EXTENSION_HELP_INDEX.get(self._HELP_INDEX, {})
1150+
if extension_help_index:
1151+
logger.debug("Blending packaged help index with extension help overlay (%d groups, %d commands).",
1152+
len(extension_help_index.get('groups') or {}),
1153+
len(extension_help_index.get('commands') or {}))
1154+
return self._blend_help_indices(packaged_help_index, extension_help_index)
1155+
1156+
# Clear stale overlay cache if schema exists but metadata is invalid.
1157+
if self.EXTENSION_HELP_INDEX.get(self._HELP_INDEX):
1158+
self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_VERSION] = ""
1159+
self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = ""
1160+
self.EXTENSION_HELP_INDEX[self._HELP_INDEX] = {}
1161+
1162+
if self._has_non_always_loaded_extensions():
1163+
logger.debug("Extension help overlay unavailable on latest profile. Triggering refresh via full load.")
1164+
return None
1165+
1166+
logger.debug("Using packaged help index with %d entries", len(packaged_help_index))
1167+
return packaged_help_index
11121168

11131169
if not self._is_index_valid():
11141170
return None
@@ -1127,6 +1183,20 @@ def set_help_index(self, help_data):
11271183
11281184
:param help_data: Help index data structure containing groups and commands
11291185
"""
1186+
if self.cloud_profile == 'latest':
1187+
packaged_help_index = self._load_packaged_help_index() or {'groups': {}, 'commands': {}}
1188+
extension_help_overlay = self._build_extension_help_overlay(packaged_help_index, help_data)
1189+
1190+
self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_VERSION] = __version__
1191+
self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = self.cloud_profile
1192+
self.EXTENSION_HELP_INDEX[self._HELP_INDEX] = extension_help_overlay
1193+
1194+
# Keep local full help cache empty for latest; packaged base + extension overlay are authoritative.
1195+
self.HELP_INDEX[self._HELP_INDEX] = {}
1196+
if self.INDEX.get(self._HELP_INDEX):
1197+
self.INDEX[self._HELP_INDEX] = {}
1198+
return
1199+
11301200
self.HELP_INDEX[self._HELP_INDEX] = help_data
11311201
# Clear legacy key if it exists in commandIndex.json.
11321202
if self.INDEX.get(self._HELP_INDEX):
@@ -1187,6 +1257,9 @@ def invalidate(self):
11871257
self.EXTENSION_INDEX[self._COMMAND_INDEX_VERSION] = ""
11881258
self.EXTENSION_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = ""
11891259
self.EXTENSION_INDEX[self._COMMAND_INDEX] = {}
1260+
self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_VERSION] = ""
1261+
self.EXTENSION_HELP_INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = ""
1262+
self.EXTENSION_HELP_INDEX[self._HELP_INDEX] = {}
11901263
self.HELP_INDEX[self._HELP_INDEX] = {}
11911264
# Clear legacy key if it exists in commandIndex.json.
11921265
if self.INDEX.get(self._HELP_INDEX):

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ def __len__(self):
103103
# HELP_INDEX contains cached help summaries for top-level help display
104104
HELP_INDEX = Session()
105105

106+
# EXTENSION_HELP_INDEX contains extension-only help overlay for top-level help display
107+
EXTENSION_HELP_INDEX = Session()
108+
106109
# VERSIONS provides local versions and pypi versions.
107110
# DO NOT USE it to get the current version of azure-cli,
108111
# it could be lagged behind and can be used to check whether

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def __init__(self, commands_loader_cls=None, random_config_dir=False, **kwargs):
3333
self.env_patch.start()
3434

3535
# Always copy command index to avoid initializing it again
36-
files_to_copy = ['commandIndex.json', 'extensionIndex.json', 'helpIndex.json']
36+
files_to_copy = ['commandIndex.json', 'extensionIndex.json', 'helpIndex.json', 'extensionHelpIndex.json']
3737
# In recording mode, copy login credentials from global config dir to the dummy config dir
3838
if os.getenv(ENV_VAR_TEST_LIVE, '').lower() == 'true':
3939
files_to_copy.extend([

src/azure-cli-core/azure/cli/core/tests/test_help.py

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,13 @@ def tearDown(self):
164164
shutil.rmtree(self._tempdirName)
165165
self.helps.clear()
166166
# Invalidate help cache to prevent test data from polluting production cache
167-
from azure.cli.core._session import HELP_INDEX, INDEX
167+
from azure.cli.core._session import EXTENSION_HELP_INDEX, HELP_INDEX, INDEX
168+
if 'helpIndex' in EXTENSION_HELP_INDEX:
169+
del EXTENSION_HELP_INDEX['helpIndex']
170+
if 'version' in EXTENSION_HELP_INDEX:
171+
del EXTENSION_HELP_INDEX['version']
172+
if 'cloudProfile' in EXTENSION_HELP_INDEX:
173+
del EXTENSION_HELP_INDEX['cloudProfile']
168174
if 'helpIndex' in HELP_INDEX:
169175
del HELP_INDEX['helpIndex']
170176
if 'helpIndex' in INDEX:
@@ -543,8 +549,8 @@ def test_help_cache_extraction(self):
543549
self.assertEqual(commands['login']['summary'], 'Log in to Azure')
544550

545551
def test_help_cache_storage_and_retrieval(self):
546-
"""Test that help cache is stored and can be retrieved."""
547-
from azure.cli.core import CommandIndex
552+
"""Test non-latest help cache remains local and retrievable."""
553+
from azure.cli.core import CommandIndex, __version__
548554
from azure.cli.core._session import HELP_INDEX
549555

550556
test_help_data = {
@@ -556,8 +562,11 @@ def test_help_cache_storage_and_retrieval(self):
556562
}
557563
}
558564

559-
command_index = CommandIndex(self.test_cli)
560-
command_index.set_help_index(test_help_data)
565+
with mock.patch.object(self.test_cli.cloud, 'profile', '2019-03-01-hybrid'):
566+
command_index = CommandIndex(self.test_cli)
567+
command_index.version = __version__
568+
command_index.cloud_profile = '2019-03-01-hybrid'
569+
command_index.set_help_index(test_help_data)
561570

562571
retrieved = HELP_INDEX.get('helpIndex')
563572

@@ -570,7 +579,7 @@ def test_help_cache_storage_and_retrieval(self):
570579
def test_help_cache_invalidation(self):
571580
"""Test that cache is invalidated correctly."""
572581
from azure.cli.core import CommandIndex
573-
from azure.cli.core._session import HELP_INDEX
582+
from azure.cli.core._session import EXTENSION_HELP_INDEX, HELP_INDEX
574583

575584
test_help_data = {'root': {'groups': {}, 'commands': {}}}
576585
command_index = CommandIndex(self.test_cli)
@@ -581,9 +590,10 @@ def test_help_cache_invalidation(self):
581590
command_index.invalidate()
582591

583592
self.assertEqual(HELP_INDEX.get('helpIndex'), {})
593+
self.assertEqual(EXTENSION_HELP_INDEX.get('helpIndex'), {})
584594

585595
def test_help_cache_legacy_migration(self):
586-
"""Test legacy helpIndex migration from commandIndex.json to helpIndex.json."""
596+
"""Test legacy helpIndex migration from commandIndex.json to helpIndex.json for non-latest."""
587597
from azure.cli.core import CommandIndex, __version__
588598
from azure.cli.core._session import HELP_INDEX, INDEX
589599

@@ -592,12 +602,13 @@ def test_help_cache_legacy_migration(self):
592602
'commands': {'legacy-cmd': {'summary': 'Legacy command', 'tags': ''}}
593603
}
594604

595-
command_index = CommandIndex(self.test_cli)
596-
INDEX[CommandIndex._COMMAND_INDEX_VERSION] = __version__
597-
INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = self.test_cli.cloud.profile
598-
INDEX['helpIndex'] = test_help_data
605+
with mock.patch.object(self.test_cli.cloud, 'profile', '2019-03-01-hybrid'):
606+
command_index = CommandIndex(self.test_cli)
607+
INDEX[CommandIndex._COMMAND_INDEX_VERSION] = __version__
608+
INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = '2019-03-01-hybrid'
609+
INDEX['helpIndex'] = test_help_data
599610

600-
migrated = command_index.get_help_index()
611+
migrated = command_index.get_help_index()
601612

602613
self.assertEqual(migrated, test_help_data)
603614
self.assertEqual(HELP_INDEX.get('helpIndex'), test_help_data)
@@ -634,11 +645,65 @@ def test_help_index_uses_packaged_latest_without_local_index(self):
634645
'commands': {'version': {'summary': 'Show version.', 'tags': ''}}
635646
}
636647

637-
with mock.patch.object(CommandIndex, '_load_packaged_help_index', return_value=packaged_help_data):
648+
with mock.patch.object(CommandIndex, '_load_packaged_help_index', return_value=packaged_help_data), \
649+
mock.patch.object(CommandIndex, '_has_non_always_loaded_extensions', return_value=False):
638650
help_index = command_index.get_help_index()
639651

640652
self.assertEqual(help_index, packaged_help_data)
641653

654+
def test_help_index_latest_missing_overlay_with_extensions_triggers_refresh(self):
655+
"""Test latest profile returns None to force refresh when extension help overlay is unavailable."""
656+
from azure.cli.core import CommandIndex
657+
from azure.cli.core._session import EXTENSION_HELP_INDEX, HELP_INDEX, INDEX
658+
659+
command_index = CommandIndex(self.test_cli)
660+
661+
INDEX[CommandIndex._COMMAND_INDEX_VERSION] = ""
662+
INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = ""
663+
INDEX[CommandIndex._COMMAND_INDEX] = {}
664+
HELP_INDEX[CommandIndex._HELP_INDEX] = {}
665+
EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_VERSION] = ""
666+
EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = ""
667+
EXTENSION_HELP_INDEX[CommandIndex._HELP_INDEX] = {}
668+
669+
packaged_help_data = {
670+
'groups': {'vm': {'summary': 'Manage VMs.', 'tags': ''}},
671+
'commands': {'version': {'summary': 'Show version.', 'tags': ''}}
672+
}
673+
674+
with mock.patch.object(CommandIndex, '_load_packaged_help_index', return_value=packaged_help_data), \
675+
mock.patch.object(CommandIndex, '_has_non_always_loaded_extensions', return_value=True):
676+
help_index = command_index.get_help_index()
677+
678+
self.assertIsNone(help_index)
679+
680+
def test_help_index_latest_blends_packaged_with_extension_overlay(self):
681+
"""Test latest profile blends packaged help with extension help overlay."""
682+
from azure.cli.core import CommandIndex, __version__
683+
from azure.cli.core._session import EXTENSION_HELP_INDEX
684+
685+
command_index = CommandIndex(self.test_cli)
686+
687+
EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_VERSION] = __version__
688+
EXTENSION_HELP_INDEX[CommandIndex._COMMAND_INDEX_CLOUD_PROFILE] = self.test_cli.cloud.profile
689+
EXTENSION_HELP_INDEX[CommandIndex._HELP_INDEX] = {
690+
'groups': {'ext-group': {'summary': 'Extension group summary.', 'tags': ''}},
691+
'commands': {'ext-cmd': {'summary': 'Extension command summary.', 'tags': ''}}
692+
}
693+
694+
packaged_help_data = {
695+
'groups': {'vm': {'summary': 'Manage VMs.', 'tags': ''}},
696+
'commands': {'version': {'summary': 'Show version.', 'tags': ''}}
697+
}
698+
699+
with mock.patch.object(CommandIndex, '_load_packaged_help_index', return_value=packaged_help_data):
700+
help_index = command_index.get_help_index()
701+
702+
self.assertIn('vm', help_index['groups'])
703+
self.assertIn('ext-group', help_index['groups'])
704+
self.assertIn('version', help_index['commands'])
705+
self.assertIn('ext-cmd', help_index['commands'])
706+
642707
def test_show_cached_help_output(self):
643708
"""Test that cached help is displayed correctly."""
644709
from azure.cli.core._help import AzCliHelp

0 commit comments

Comments
 (0)