Skip to content

Commit 343e28e

Browse files
{Core} Top-level tab completion performance improvements (#32616)
1 parent 6a70dae commit 343e28e

File tree

3 files changed

+85
-1
lines changed

3 files changed

+85
-1
lines changed

.vscode/launch.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,26 @@
3838
"--help"
3939
],
4040
"console": "integratedTerminal",
41+
},
42+
{
43+
"name": "Azure CLI Debug Tab Completion (External Console)",
44+
"type": "debugpy",
45+
"request": "launch",
46+
"program": "${workspaceFolder}/src/azure-cli/azure/cli/__main__.py",
47+
"args": [],
48+
"console": "externalTerminal",
49+
"cwd": "${workspaceFolder}",
50+
"env": {
51+
"_ARGCOMPLETE": "1",
52+
"COMP_LINE": "az vm create --",
53+
"COMP_POINT": "18",
54+
"_ARGCOMPLETE_SUPPRESS_SPACE": "0",
55+
"_ARGCOMPLETE_IFS": "\n",
56+
"_ARGCOMPLETE_SHELL": "powershell",
57+
"ARGCOMPLETE_USE_TEMPFILES": "1",
58+
"_ARGCOMPLETE_STDOUT_FILENAME": "C:\\temp\\az_debug_completion.txt"
59+
},
60+
"justMyCode": false
4161
}
4262
]
4363
}

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

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
EXCLUDED_PARAMS = ['self', 'raw', 'polling', 'custom_headers', 'operation_config',
2929
'content_version', 'kwargs', 'client', 'no_wait']
3030
EVENT_FAILED_EXTENSION_LOAD = 'MainLoader.OnFailedExtensionLoad'
31+
# Marker used by CommandIndex.get() to signal top-level tab completion optimization
32+
TOP_LEVEL_COMPLETION_MARKER = '__top_level_completion__'
3133

3234
# [Reserved, in case of future usage]
3335
# Modules that will always be loaded. They don't expose commands but hook into CLI core.
@@ -225,6 +227,24 @@ def __init__(self, cli_ctx=None):
225227
self.cmd_to_loader_map = {}
226228
self.loaders = []
227229

230+
def _create_stub_commands_for_completion(self, command_names):
231+
"""Create stub commands for top-level tab completion optimization.
232+
233+
Stub commands allow argcomplete to parse command names without loading modules.
234+
235+
:param command_names: List of command names to create stubs for
236+
"""
237+
from azure.cli.core.commands import AzCliCommand
238+
239+
def _stub_handler(*_args, **_kwargs):
240+
"""Stub command handler used only for argument completion."""
241+
return None
242+
243+
for cmd_name in command_names:
244+
if cmd_name not in self.command_table:
245+
# Stub commands only need names for argcomplete parser construction.
246+
self.command_table[cmd_name] = AzCliCommand(self, cmd_name, _stub_handler)
247+
228248
def _update_command_definitions(self):
229249
for cmd_name in self.command_table:
230250
loaders = self.cmd_to_loader_map[cmd_name]
@@ -423,9 +443,16 @@ def _get_extension_suppressions(mod_loaders):
423443
index_result = command_index.get(args)
424444
if index_result:
425445
index_modules, index_extensions = index_result
446+
447+
if index_modules == TOP_LEVEL_COMPLETION_MARKER:
448+
self._create_stub_commands_for_completion(index_extensions)
449+
_update_command_table_from_extensions([], ALWAYS_LOADED_EXTENSIONS)
450+
return self.command_table
451+
426452
# Always load modules and extensions, because some of them (like those in
427453
# ALWAYS_LOADED_EXTENSIONS) don't expose a command, but hooks into handlers in CLI core
428454
_update_command_table_from_modules(args, index_modules)
455+
429456
# The index won't contain suppressed extensions
430457
_update_command_table_from_extensions([], index_extensions)
431458

@@ -473,7 +500,6 @@ def _get_extension_suppressions(mod_loaders):
473500
else:
474501
logger.debug("No module found from index for '%s'", args)
475502

476-
# No module found from the index. Load all command modules and extensions
477503
logger.debug("Loading all modules and extensions")
478504
_update_command_table_from_modules(args)
479505

@@ -662,6 +688,23 @@ def __init__(self, cli_ctx=None):
662688
self.cloud_profile = cli_ctx.cloud.profile
663689
self.cli_ctx = cli_ctx
664690

691+
def _get_top_level_completion_commands(self):
692+
"""Get top-level command names for tab completion optimization.
693+
694+
Returns marker and list of top-level commands (e.g., 'network', 'vm') for creating
695+
stub commands without module loading. Returns None if index is empty, triggering
696+
fallback to full module loading.
697+
698+
:return: tuple of (TOP_LEVEL_COMPLETION_MARKER, list of top-level command names) or None
699+
"""
700+
index = self.INDEX.get(self._COMMAND_INDEX) or {}
701+
if not index:
702+
logger.debug("Command index is empty, will fall back to loading all modules")
703+
return None
704+
top_level_commands = list(index.keys())
705+
logger.debug("Top-level completion: %d commands available", len(top_level_commands))
706+
return TOP_LEVEL_COMPLETION_MARKER, top_level_commands
707+
665708
def get(self, args):
666709
"""Get the corresponding module and extension list of a command.
667710
@@ -681,6 +724,9 @@ def get(self, args):
681724
# Make sure the top-level command is provided, like `az version`.
682725
# Skip command index for `az` or `az --help`.
683726
if not args or args[0].startswith('-'):
727+
# For top-level completion (az [tab])
728+
if not args and self.cli_ctx.data.get('completer_active'):
729+
return self._get_top_level_completion_commands()
684730
return None
685731

686732
# Get the top-level command, like `network` in `network vnet create -h`

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,21 @@ def dummy_completor(*args, **kwargs):
6464
with open('argcomplete.out') as f:
6565
self.assertEqual(f.read(), 'dummystorage ')
6666
os.remove('argcomplete.out')
67+
68+
def test_top_level_completion(self):
69+
"""Test that top-level completion (az [tab]) returns command names from index"""
70+
import os
71+
import sys
72+
73+
if sys.platform == 'win32':
74+
self.skipTest('Skip argcomplete test on Windows')
75+
76+
run_cmd(['az'], env=self.argcomplete_env('az ', '3'))
77+
with open('argcomplete.out') as f:
78+
completions = f.read().split()
79+
# Verify common top-level commands are present
80+
self.assertIn('account', completions)
81+
self.assertIn('vm', completions)
82+
self.assertIn('network', completions)
83+
self.assertIn('storage', completions)
84+
os.remove('argcomplete.out')

0 commit comments

Comments
 (0)