Skip to content

Commit ade38e2

Browse files
DanielMicrosoftnddq
authored andcommitted
{Core} Add prebuilt command index/help index for core modules (Azure#32929)
1 parent 66b306b commit ade38e2

12 files changed

Lines changed: 1966 additions & 41 deletions

File tree

.gitattributes

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@
1515
build_scripts/windows/scripts/az eol=lf
1616
# sh scripts should be LF
1717
*.sh eol=lf
18+
19+
# Generated latest index assets should always use LF to avoid cross-platform churn
20+
src/azure-cli-core/azure/cli/core/commandIndex.latest.json text eol=lf
21+
src/azure-cli-core/azure/cli/core/helpIndex.latest.json text eol=lf

azure-pipelines.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,23 @@ jobs:
10801080
docker pull ${DISTRO_BASE_IMAGE}
10811081
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"
10821082
1083+
- job: VerifyLatestIndices
1084+
displayName: "Verify latest index assets"
1085+
timeoutInMinutes: 20
1086+
pool:
1087+
name: ${{ variables.ubuntu_pool }}
1088+
steps:
1089+
- task: UsePythonVersion@0
1090+
displayName: 'Use Python 3.13'
1091+
inputs:
1092+
versionSpec: 3.13
1093+
- template: .azure-pipelines/templates/azdev_setup.yml
1094+
- bash: |
1095+
set -ev
1096+
. env/bin/activate
1097+
python scripts/generate_latest_indices.py verify
1098+
displayName: 'Verify generated latest indices'
1099+
10831100
- job: CheckStyle
10841101
displayName: "Check CLI Style"
10851102
timeoutInMinutes: 120

scripts/generate_latest_indices.py

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
#!/usr/bin/env python
2+
3+
# --------------------------------------------------------------------------------------------
4+
# Copyright (c) Microsoft Corporation. All rights reserved.
5+
# Licensed under the MIT License. See License.txt in the project root for license information.
6+
# --------------------------------------------------------------------------------------------
7+
8+
"""Generate or verify packaged latest command/help index assets.
9+
10+
This script updates or validates:
11+
- src/azure-cli-core/azure/cli/core/commandIndex.latest.json
12+
- src/azure-cli-core/azure/cli/core/helpIndex.latest.json
13+
14+
The script runs in an isolated temp AZURE_CONFIG_DIR and with extension directories
15+
redirected to empty folders to avoid local machine state affecting output.
16+
"""
17+
18+
import argparse
19+
import json
20+
import os
21+
import sys
22+
import tempfile
23+
from contextlib import contextmanager
24+
from pathlib import Path
25+
26+
27+
REPO_ROOT = Path(__file__).resolve().parents[1]
28+
CORE_DIR = REPO_ROOT / 'src' / 'azure-cli-core' / 'azure' / 'cli' / 'core'
29+
COMMAND_INDEX_PATH = CORE_DIR / 'commandIndex.latest.json'
30+
HELP_INDEX_PATH = CORE_DIR / 'helpIndex.latest.json'
31+
CORE_COMMAND_MODULE_PREFIX = 'azure.cli.command_modules.'
32+
33+
34+
def _bootstrap_repo_paths():
35+
"""Ensure local source trees are importable when running from repo root."""
36+
source_roots = [
37+
REPO_ROOT / 'src' / 'azure-cli-core',
38+
REPO_ROOT / 'src' / 'azure-cli',
39+
REPO_ROOT / 'src' / 'azure-cli-telemetry',
40+
REPO_ROOT / 'src' / 'azure-cli-testsdk',
41+
]
42+
43+
for source_root in source_roots:
44+
source_root_str = str(source_root)
45+
if source_root_str not in sys.path:
46+
sys.path.insert(0, source_root_str)
47+
48+
49+
@contextmanager
50+
def _isolated_cli_environment():
51+
"""Temporarily isolate config/extension directories for deterministic output."""
52+
tracked_vars = ['AZURE_CONFIG_DIR', 'AZURE_EXTENSION_DIR']
53+
previous = {name: os.environ.get(name) for name in tracked_vars}
54+
55+
with tempfile.TemporaryDirectory(prefix='az-index-gen-') as temp_config_dir:
56+
extension_dir = os.path.join(temp_config_dir, 'cliextensions')
57+
os.makedirs(extension_dir, exist_ok=True)
58+
59+
os.environ['AZURE_CONFIG_DIR'] = temp_config_dir
60+
os.environ['AZURE_EXTENSION_DIR'] = extension_dir
61+
62+
try:
63+
yield temp_config_dir, extension_dir
64+
finally:
65+
for name, value in previous.items():
66+
if value is None:
67+
os.environ.pop(name, None)
68+
else:
69+
os.environ[name] = value
70+
71+
72+
def _read_json(path):
73+
if not path.is_file():
74+
return None
75+
with path.open('r', encoding='utf-8-sig') as handle:
76+
return json.load(handle)
77+
78+
79+
def _order_keys_like_template(generated, template):
80+
"""Preserve existing key order when possible, append new keys in sorted order."""
81+
if not isinstance(generated, dict):
82+
return generated
83+
84+
if not isinstance(template, dict):
85+
return {key: generated[key] for key in sorted(generated)}
86+
87+
ordered = {}
88+
for key in template:
89+
if key in generated:
90+
ordered[key] = generated[key]
91+
92+
for key in sorted(generated):
93+
if key not in ordered:
94+
ordered[key] = generated[key]
95+
96+
return ordered
97+
98+
99+
def _extract_builtin_module_name(command):
100+
"""Return built-in module name for a command table entry, or None for extension entries."""
101+
command_source = getattr(command, 'command_source', None)
102+
if isinstance(command_source, str) and command_source.startswith(CORE_COMMAND_MODULE_PREFIX):
103+
return command_source
104+
105+
command_loader = getattr(command, 'loader', None)
106+
loader_module = getattr(command_loader, '__module__', None)
107+
if isinstance(loader_module, str) and loader_module.startswith(CORE_COMMAND_MODULE_PREFIX):
108+
return loader_module
109+
110+
return None
111+
112+
113+
def _build_command_index_map(command_table):
114+
command_index = {}
115+
for command_name, command in command_table.items():
116+
top_command = command_name.split()[0]
117+
module_name = _extract_builtin_module_name(command)
118+
if not module_name:
119+
continue
120+
121+
modules = command_index.setdefault(top_command, [])
122+
if module_name not in modules:
123+
modules.append(module_name)
124+
125+
for top_command, modules in command_index.items():
126+
command_index[top_command] = sorted(modules)
127+
128+
return command_index
129+
130+
131+
def _build_help_index_map(cli_ctx, commands_loader):
132+
from azure.cli.core._help import CliGroupHelpFile, extract_help_index_data
133+
from azure.cli.core.parser import AzCliCommandParser
134+
135+
parser = AzCliCommandParser(cli_ctx)
136+
parser.load_command_table(commands_loader)
137+
138+
root_subparser = parser.subparsers.get(tuple())
139+
if not root_subparser:
140+
return {'groups': {}, 'commands': {}}
141+
142+
help_obj = cli_ctx.help_cls(cli_ctx)
143+
root_help = CliGroupHelpFile(help_obj, '', root_subparser)
144+
root_help.load(root_subparser)
145+
146+
groups, commands = extract_help_index_data(root_help)
147+
148+
normalized_groups = {
149+
group_name: {
150+
'summary': group_data.get('summary', ''),
151+
'tags': group_data.get('tags', '')
152+
}
153+
for group_name, group_data in groups.items()
154+
}
155+
normalized_commands = {
156+
command_name: {
157+
'summary': command_data.get('summary', ''),
158+
'tags': command_data.get('tags', '')
159+
}
160+
for command_name, command_data in commands.items()
161+
}
162+
163+
return {
164+
'groups': {key: normalized_groups[key] for key in sorted(normalized_groups)},
165+
'commands': {key: normalized_commands[key] for key in sorted(normalized_commands)}
166+
}
167+
168+
169+
def _generate_documents():
170+
_bootstrap_repo_paths()
171+
172+
with _isolated_cli_environment() as (temp_config_dir, extension_dir):
173+
from azure.cli.core import CommandIndex, __version__, get_default_cli
174+
import azure.cli.core.extension as extension_module
175+
176+
# Hard pin extension discovery directories so local/global installed extensions do not leak in.
177+
extension_module.EXTENSIONS_DIR = extension_dir
178+
extension_module.EXTENSIONS_SYS_DIR = os.path.join(temp_config_dir, 'empty-system-extensions')
179+
extension_module.DEV_EXTENSION_SOURCES = []
180+
os.makedirs(extension_module.EXTENSIONS_SYS_DIR, exist_ok=True)
181+
182+
cli = get_default_cli()
183+
cli.cloud.profile = 'latest'
184+
cli.data['completer_active'] = False
185+
186+
invoker = cli.invocation_cls(
187+
cli_ctx=cli,
188+
commands_loader_cls=cli.commands_loader_cls,
189+
parser_cls=cli.parser_cls,
190+
help_cls=cli.help_cls
191+
)
192+
cli.invocation = invoker
193+
commands_loader = invoker.commands_loader
194+
command_table = commands_loader.load_command_table(None)
195+
196+
current_command_doc = _read_json(COMMAND_INDEX_PATH) or {}
197+
current_help_doc = _read_json(HELP_INDEX_PATH) or {}
198+
199+
generated_command_index = _build_command_index_map(command_table)
200+
generated_help_index = _build_help_index_map(cli, commands_loader)
201+
202+
ordered_command_index = _order_keys_like_template(
203+
generated_command_index,
204+
current_command_doc.get(CommandIndex._COMMAND_INDEX) # pylint: disable=protected-access
205+
)
206+
207+
help_template = current_help_doc.get(CommandIndex._HELP_INDEX, {}) # pylint: disable=protected-access
208+
ordered_help_groups = _order_keys_like_template(generated_help_index['groups'], help_template.get('groups'))
209+
ordered_help_commands = _order_keys_like_template(generated_help_index['commands'], help_template.get('commands'))
210+
211+
command_doc = {
212+
CommandIndex._COMMAND_INDEX_VERSION: __version__, # pylint: disable=protected-access
213+
CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: 'latest', # pylint: disable=protected-access
214+
CommandIndex._COMMAND_INDEX: ordered_command_index # pylint: disable=protected-access
215+
}
216+
217+
help_doc = {
218+
CommandIndex._COMMAND_INDEX_VERSION: __version__, # pylint: disable=protected-access
219+
CommandIndex._COMMAND_INDEX_CLOUD_PROFILE: 'latest', # pylint: disable=protected-access
220+
CommandIndex._HELP_INDEX: { # pylint: disable=protected-access
221+
'groups': ordered_help_groups,
222+
'commands': ordered_help_commands
223+
}
224+
}
225+
226+
return command_doc, help_doc
227+
228+
229+
def _serialize_json(document):
230+
return json.dumps(document, indent=2) + '\n'
231+
232+
233+
def _write_file(path, content):
234+
path.parent.mkdir(parents=True, exist_ok=True)
235+
with path.open('w', encoding='utf-8', newline='\n') as handle:
236+
handle.write(content)
237+
238+
239+
def _load_text(path):
240+
if not path.is_file():
241+
return None
242+
return path.read_text(encoding='utf-8-sig')
243+
244+
245+
def _run_generate(command_text, help_text):
246+
current_command_text = _load_text(COMMAND_INDEX_PATH)
247+
current_help_text = _load_text(HELP_INDEX_PATH)
248+
249+
updated_files = []
250+
251+
if current_command_text != command_text:
252+
_write_file(COMMAND_INDEX_PATH, command_text)
253+
updated_files.append(COMMAND_INDEX_PATH)
254+
255+
if current_help_text != help_text:
256+
_write_file(HELP_INDEX_PATH, help_text)
257+
updated_files.append(HELP_INDEX_PATH)
258+
259+
if updated_files:
260+
print('Updated generated latest index files:')
261+
for path in updated_files:
262+
print(f' - {path.relative_to(REPO_ROOT)}')
263+
else:
264+
print('Latest index files are already up-to-date.')
265+
266+
return 0
267+
268+
269+
def _run_verify(command_text, help_text):
270+
mismatched = []
271+
272+
if _load_text(COMMAND_INDEX_PATH) != command_text:
273+
mismatched.append(COMMAND_INDEX_PATH)
274+
if _load_text(HELP_INDEX_PATH) != help_text:
275+
mismatched.append(HELP_INDEX_PATH)
276+
277+
if mismatched:
278+
print('Generated latest index files are out of date:')
279+
for path in mismatched:
280+
print(f' - {path.relative_to(REPO_ROOT)}')
281+
print('Run:')
282+
print(' python scripts/generate_latest_indices.py generate')
283+
return 1
284+
285+
print('Verified: latest index files are up-to-date.')
286+
return 0
287+
288+
289+
def _parse_args():
290+
parser = argparse.ArgumentParser(
291+
description='Generate or verify packaged latest command and help index JSON files.'
292+
)
293+
parser.add_argument(
294+
'mode',
295+
nargs='?',
296+
choices=['generate', 'verify'],
297+
default='generate',
298+
help='Mode to run. generate writes files; verify checks drift and exits non-zero on mismatch.'
299+
)
300+
return parser.parse_args()
301+
302+
303+
def main():
304+
args = _parse_args()
305+
306+
command_doc, help_doc = _generate_documents()
307+
command_text = _serialize_json(command_doc)
308+
help_text = _serialize_json(help_doc)
309+
310+
if args.mode == 'verify':
311+
return _run_verify(command_text, help_text)
312+
313+
return _run_generate(command_text, help_text)
314+
315+
316+
if __name__ == '__main__':
317+
sys.exit(main())

0 commit comments

Comments
 (0)