From b52fb23e454aee79e5b1314cd31c1c1bcd688873 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 18 Dec 2025 11:28:57 +1100 Subject: [PATCH 01/16] feature: add launch.json for tab completion --- .vscode/launch.json | 19 +++++++++++++++++++ .../azure/cli/core/commands/__init__.py | 1 + 2 files changed, 20 insertions(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index e7ed7a11353..cd23a7aa89b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -38,6 +38,25 @@ "--help" ], "console": "integratedTerminal", + }, + { + "name": "Azure CLI Debug Tab Completion (External Console)", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/src/azure-cli/azure/cli/__main__.py", + "args": [], + "console": "externalTerminal", + "cwd": "${workspaceFolder}", + "env": { + "_ARGCOMPLETE": "1", + "COMP_LINE": "az vm ", + "COMP_POINT": "6", + "_ARGCOMPLETE_SUPPRESS_SPACE": "0", + "_ARGCOMPLETE_IFS": "\n", + "_ARGCOMPLETE_SHELL": "powershell", + "ARGCOMPLETE_USE_TEMPFILES": "1" + }, + "justMyCode": false } ] } \ No newline at end of file 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 696b6093f5d..e5a39925a80 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -519,6 +519,7 @@ def execute(self, args): args = _pre_command_table_create(self.cli_ctx, args) self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) + # @TODO: this is one bottleneck self.commands_loader.load_command_table(args) self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, load_cmd_tbl_func=self.commands_loader.load_command_table, args=args) From ba5cb3299faabe1020b9f302a8df6b5ed8947adb Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Fri, 19 Dec 2025 14:36:50 +1100 Subject: [PATCH 02/16] wip: debugging statements --- .vscode/launch.json | 3 ++- src/azure-cli-core/azure/cli/core/__init__.py | 10 ++++++++++ .../azure/cli/core/commands/__init__.py | 10 ++++++++++ src/azure-cli/azure/cli/__main__.py | 12 ++++++++++++ .../azure/cli/command_modules/vm/__init__.py | 3 ++- 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index cd23a7aa89b..bfb73c66429 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -54,7 +54,8 @@ "_ARGCOMPLETE_SUPPRESS_SPACE": "0", "_ARGCOMPLETE_IFS": "\n", "_ARGCOMPLETE_SHELL": "powershell", - "ARGCOMPLETE_USE_TEMPFILES": "1" + "ARGCOMPLETE_USE_TEMPFILES": "1", + "_ARGCOMPLETE_STDOUT_FILENAME": "C:\\temp\\az_debug_completion.txt" }, "justMyCode": false } diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 0783ed923ac..c3cf3d4cdc7 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -436,9 +436,19 @@ def _get_extension_suppressions(mod_loaders): index_modules, index_extensions = index_result # 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 + import time + import sys + start_time = time.time() + print(f"[PERF] Loading modules from index: {index_modules}", file=sys.stderr, flush=True) _update_command_table_from_modules(args, index_modules) + elapsed_modules = time.time() - start_time + print(f"[PERF] Loaded modules in {elapsed_modules:.3f} seconds", file=sys.stderr, flush=True) + # The index won't contain suppressed extensions + start_time = time.time() _update_command_table_from_extensions([], index_extensions) + elapsed_extensions = time.time() - start_time + print(f"[PERF] Loaded extensions in {elapsed_extensions:.3f} seconds", file=sys.stderr, flush=True) 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 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 e5a39925a80..b27910ae549 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -506,6 +506,13 @@ class AzCliCommandInvoker(CommandInvoker): # pylint: disable=too-many-statements,too-many-locals,too-many-branches def execute(self, args): + import time + import sys + import os + _start_time = time.time() + if os.environ.get('_ARGCOMPLETE'): + print(f"[PERF] Starting completion at {_start_time}", file=sys.stderr, flush=True) + from knack.events import (EVENT_INVOKER_PRE_CMD_TBL_CREATE, EVENT_INVOKER_POST_CMD_TBL_CREATE, EVENT_INVOKER_CMD_TBL_LOADED, EVENT_INVOKER_PRE_PARSE_ARGS, EVENT_INVOKER_POST_PARSE_ARGS, @@ -588,6 +595,9 @@ def execute(self, args): if args[0].lower() == 'help': args[0] = '--help' + if os.environ.get('_ARGCOMPLETE'): + elapsed = time.time() - _start_time + print(f"[PERF] About to enable autocomplete. Elapsed so far: {elapsed:.3f} seconds", file=sys.stderr, flush=True) self.parser.enable_autocomplete() self.cli_ctx.raise_event(EVENT_INVOKER_PRE_PARSE_ARGS, args=args) diff --git a/src/azure-cli/azure/cli/__main__.py b/src/azure-cli/azure/cli/__main__.py index befd36668a6..e0791600788 100644 --- a/src/azure-cli/azure/cli/__main__.py +++ b/src/azure-cli/azure/cli/__main__.py @@ -9,6 +9,11 @@ start_time = timeit.default_timer() import sys +import os + +# Track completion performance +if os.environ.get('_ARGCOMPLETE'): + print(f"[PERF] az CLI entry point at {start_time}", file=sys.stderr, flush=True) from azure.cli.core import telemetry from azure.cli.core import get_default_cli @@ -29,6 +34,10 @@ def cli_main(cli, args): az_cli = get_default_cli() +if os.environ.get('_ARGCOMPLETE'): + elapsed = timeit.default_timer() - start_time + print(f"[PERF] CLI initialized in {elapsed:.3f} seconds", file=sys.stderr, flush=True) + telemetry.set_application(az_cli, ARGCOMPLETE_ENV_NAME) # Log the init finish time @@ -38,6 +47,9 @@ def cli_main(cli, args): try: telemetry.start() + if os.environ.get('_ARGCOMPLETE'): + print(f"[PERF] Calling cli_main() at {timeit.default_timer():.3f}", file=sys.stderr, flush=True) + exit_code = cli_main(az_cli, sys.argv[1:]) if exit_code == 0: diff --git a/src/azure-cli/azure/cli/command_modules/vm/__init__.py b/src/azure-cli/azure/cli/command_modules/vm/__init__.py index 42d6e85ca60..6d5f1ec1918 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/vm/__init__.py @@ -13,7 +13,8 @@ from azure.cli.core import AzCommandsLoader from azure.cli.core.profiles import ResourceType -import azure.cli.command_modules.vm._help # pylint: disable=unused-import +# Don't import help during tab completion - it's expensive and not needed +# import azure.cli.command_modules.vm._help # pylint: disable=unused-import class ComputeCommandsLoader(AzCommandsLoader): From b318824de1f649b476b87344a41c59c67aa174a6 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 23 Dec 2025 12:42:50 +1100 Subject: [PATCH 03/16] wip: add debug statements --- src/azure-cli-core/azure/cli/core/__init__.py | 3 ++- 1 file changed, 2 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 c3cf3d4cdc7..e7a7814013d 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -429,15 +429,16 @@ def _get_extension_suppressions(mod_loaders): command_index = None # Set fallback=False to turn off command index in case of regression use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) + print(f"[PERF] use_command_index: {use_command_index}, args: {args}", file=sys.stderr, flush=True) if use_command_index: command_index = CommandIndex(self.cli_ctx) index_result = command_index.get(args) + print(f"[PERF] index_result: {index_result}", file=sys.stderr, flush=True) if index_result: index_modules, index_extensions = index_result # 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 import time - import sys start_time = time.time() print(f"[PERF] Loading modules from index: {index_modules}", file=sys.stderr, flush=True) _update_command_table_from_modules(args, index_modules) From 8f4d97070e7f606c0fed348e247ff157f847b879 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 23 Dec 2025 12:54:45 +1100 Subject: [PATCH 04/16] wip: --- src/azure-cli/azure/cli/command_modules/vm/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/azure-cli/azure/cli/command_modules/vm/__init__.py b/src/azure-cli/azure/cli/command_modules/vm/__init__.py index 6d5f1ec1918..42d6e85ca60 100644 --- a/src/azure-cli/azure/cli/command_modules/vm/__init__.py +++ b/src/azure-cli/azure/cli/command_modules/vm/__init__.py @@ -13,8 +13,7 @@ from azure.cli.core import AzCommandsLoader from azure.cli.core.profiles import ResourceType -# Don't import help during tab completion - it's expensive and not needed -# import azure.cli.command_modules.vm._help # pylint: disable=unused-import +import azure.cli.command_modules.vm._help # pylint: disable=unused-import class ComputeCommandsLoader(AzCommandsLoader): From 0eacf79097a285923a5eaeeb97d144866b356f58 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 23 Dec 2025 15:06:15 +1100 Subject: [PATCH 05/16] wip: more benchmarking comments --- .vscode/launch.json | 4 ++-- src/azure-cli-core/azure/cli/core/__init__.py | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index bfb73c66429..82544dba4de 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -49,8 +49,8 @@ "cwd": "${workspaceFolder}", "env": { "_ARGCOMPLETE": "1", - "COMP_LINE": "az vm ", - "COMP_POINT": "6", + "COMP_LINE": "az ", + "COMP_POINT": "3", "_ARGCOMPLETE_SUPPRESS_SPACE": "0", "_ARGCOMPLETE_IFS": "\n", "_ARGCOMPLETE_SHELL": "powershell", diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index e7a7814013d..8223e3db8b9 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -424,12 +424,16 @@ def _get_extension_suppressions(mod_loaders): self.command_table.clear() # Import announced breaking changes in azure.cli.core._breaking_change.py + import time + t1 = time.time() import_core_breaking_changes() + print(f"[PERF] Breaking changes import: {time.time()-t1:.3f}s", file=sys.stderr, flush=True) command_index = None # Set fallback=False to turn off command index in case of regression + t1 = time.time() use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) - print(f"[PERF] use_command_index: {use_command_index}, args: {args}", file=sys.stderr, flush=True) + print(f"[PERF] use_command_index: {use_command_index}, args: {args}, lookup time: {time.time()-t1:.3f}s", file=sys.stderr, flush=True) if use_command_index: command_index = CommandIndex(self.cli_ctx) index_result = command_index.get(args) @@ -497,16 +501,25 @@ def _get_extension_suppressions(mod_loaders): # No module found from the index. Load all command modules and extensions logger.debug("Loading all modules and extensions") + t1 = time.time() + print(f"[PERF] Loading ALL modules (no index)", file=sys.stderr, flush=True) _update_command_table_from_modules(args) + elapsed_all_modules = time.time() - t1 + print(f"[PERF] Loaded ALL modules in {elapsed_all_modules:.3f} seconds", file=sys.stderr, flush=True) + t1 = time.time() ext_suppressions = _get_extension_suppressions(self.loaders) # We always load extensions even if the appropriate module has been loaded # as an extension could override the commands already loaded. _update_command_table_from_extensions(ext_suppressions) + elapsed_all_extensions = time.time() - t1 + print(f"[PERF] Loaded ALL extensions in {elapsed_all_extensions:.3f} seconds", file=sys.stderr, flush=True) logger.debug("Loaded %d groups, %d commands.", len(self.command_group_table), len(self.command_table)) if use_command_index: + t1 = time.time() command_index.update(self.command_table) + print(f"[PERF] Command index update: {time.time()-t1:.3f}s", file=sys.stderr, flush=True) return self.command_table From 71cf3ddf72fd5835ed4d07cba02c693cdf21b2a4 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Tue, 23 Dec 2025 15:42:10 +1100 Subject: [PATCH 06/16] feature: adding top-level command lookup from commandIndex.json --- src/azure-cli-core/azure/cli/core/__init__.py | 27 ++++++++++++++++++- 1 file changed, 26 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 8223e3db8b9..b902aef10cf 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -440,6 +440,25 @@ def _get_extension_suppressions(mod_loaders): print(f"[PERF] index_result: {index_result}", file=sys.stderr, flush=True) if index_result: index_modules, index_extensions = index_result + + # Special case for top-level completion - create minimal command groups + if index_modules == '__top_level_completion__': + import time + from azure.cli.core.commands import AzCliCommand + start_time = time.time() + print(f"[PERF] Top-level completion mode: creating {len(index_extensions)} command stubs", file=sys.stderr, flush=True) + # index_extensions contains the command names, not extensions + for cmd_name in index_extensions: + # Create a minimal command entry for tab completion + # This allows argparse to see the command without loading the module + if cmd_name not in self.command_table: + self.command_table[cmd_name] = AzCliCommand( + self, cmd_name, lambda: None + ) + elapsed = time.time() - start_time + print(f"[PERF] Created command stubs in {elapsed:.3f} seconds", file=sys.stderr, flush=True) + return self.command_table + # 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 import time @@ -621,8 +640,14 @@ def get(self, args): return None # Make sure the top-level command is provided, like `az version`. - # Skip command index for `az` or `az --help`. + # For top-level completion (az [tab]), use a special marker to skip module loading if not args or args[0].startswith('-'): + if not args and self.cli_ctx.data.get('completer_active'): + # Return a special marker so we know to skip module loading for top-level completion + index = self.INDEX[self._COMMAND_INDEX] + all_commands = list(index.keys()) + logger.debug("Top-level completion: %d commands available", len(all_commands)) + return '__top_level_completion__', all_commands # special marker, command list return None # Get the top-level command, like `network` in `network vnet create -h` From d5ded81ebcaab0d8786083c59d3a946bfbe5d8c3 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 5 Jan 2026 16:42:43 +1100 Subject: [PATCH 07/16] wip: add total time print statement --- src/azure-cli/azure/cli/__main__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/azure-cli/azure/cli/__main__.py b/src/azure-cli/azure/cli/__main__.py index e0791600788..632a99fd3c9 100644 --- a/src/azure-cli/azure/cli/__main__.py +++ b/src/azure-cli/azure/cli/__main__.py @@ -11,8 +11,9 @@ import sys import os -# Track completion performance +# Track completion performance - store start time for argcomplete to use if os.environ.get('_ARGCOMPLETE'): + sys._cli_start_time = start_time print(f"[PERF] az CLI entry point at {start_time}", file=sys.stderr, flush=True) from azure.cli.core import telemetry @@ -67,6 +68,13 @@ def cli_main(cli, args): finally: # Log the invoke finish time invoke_finish_time = timeit.default_timer() + + if os.environ.get('_ARGCOMPLETE'): + total_time = invoke_finish_time - start_time + print(f"[PERF] ============================================", file=sys.stderr, flush=True) + print(f"[PERF] TOTAL TAB COMPLETION TIME: {total_time:.3f} seconds", file=sys.stderr, flush=True) + print(f"[PERF] ============================================", file=sys.stderr, flush=True) + logger.info("Command ran in %.3f seconds (init: %.3f, invoke: %.3f)", invoke_finish_time - start_time, init_finish_time - start_time, From 877045ac55f890493b8775686220983806ad6eaf Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 7 Jan 2026 14:50:19 +1100 Subject: [PATCH 08/16] test: add argcomplete test for top-level cmds --- .../azure/cli/core/tests/test_argcomplete.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_argcomplete.py b/src/azure-cli-core/azure/cli/core/tests/test_argcomplete.py index 52a24af9b3c..1b0047f102b 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_argcomplete.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_argcomplete.py @@ -64,3 +64,21 @@ def dummy_completor(*args, **kwargs): with open('argcomplete.out') as f: self.assertEqual(f.read(), 'dummystorage ') os.remove('argcomplete.out') + + def test_top_level_completion(self): + """Test that top-level completion (az [tab]) returns command names from index""" + import os + import sys + + if sys.platform == 'win32': + self.skipTest('Skip argcomplete test on Windows') + + run_cmd(['az'], env=self.argcomplete_env('az ', '3')) + with open('argcomplete.out') as f: + completions = f.read().split() + # Verify common top-level commands are present + self.assertIn('account', completions) + self.assertIn('vm', completions) + self.assertIn('network', completions) + self.assertIn('storage', completions) + os.remove('argcomplete.out') From c39a8ae118c781e93e48bf4c7e2e48344f85d161 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 7 Jan 2026 15:09:34 +1100 Subject: [PATCH 09/16] fix: revert perf print statements --- src/azure-cli-core/azure/cli/core/__init__.py | 28 ------------------- .../azure/cli/core/commands/__init__.py | 10 ------- src/azure-cli/azure/cli/__main__.py | 21 +------------- 3 files changed, 1 insertion(+), 58 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index b902aef10cf..7b0e6c123f0 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -424,29 +424,20 @@ def _get_extension_suppressions(mod_loaders): self.command_table.clear() # Import announced breaking changes in azure.cli.core._breaking_change.py - import time - t1 = time.time() import_core_breaking_changes() - print(f"[PERF] Breaking changes import: {time.time()-t1:.3f}s", file=sys.stderr, flush=True) command_index = None # Set fallback=False to turn off command index in case of regression - t1 = time.time() use_command_index = self.cli_ctx.config.getboolean('core', 'use_command_index', fallback=True) - print(f"[PERF] use_command_index: {use_command_index}, args: {args}, lookup time: {time.time()-t1:.3f}s", file=sys.stderr, flush=True) if use_command_index: command_index = CommandIndex(self.cli_ctx) index_result = command_index.get(args) - print(f"[PERF] index_result: {index_result}", file=sys.stderr, flush=True) if index_result: index_modules, index_extensions = index_result # Special case for top-level completion - create minimal command groups if index_modules == '__top_level_completion__': - import time from azure.cli.core.commands import AzCliCommand - start_time = time.time() - print(f"[PERF] Top-level completion mode: creating {len(index_extensions)} command stubs", file=sys.stderr, flush=True) # index_extensions contains the command names, not extensions for cmd_name in index_extensions: # Create a minimal command entry for tab completion @@ -455,24 +446,14 @@ def _get_extension_suppressions(mod_loaders): self.command_table[cmd_name] = AzCliCommand( self, cmd_name, lambda: None ) - elapsed = time.time() - start_time - print(f"[PERF] Created command stubs in {elapsed:.3f} seconds", file=sys.stderr, flush=True) return self.command_table # 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 - import time - start_time = time.time() - print(f"[PERF] Loading modules from index: {index_modules}", file=sys.stderr, flush=True) _update_command_table_from_modules(args, index_modules) - elapsed_modules = time.time() - start_time - print(f"[PERF] Loaded modules in {elapsed_modules:.3f} seconds", file=sys.stderr, flush=True) # The index won't contain suppressed extensions - start_time = time.time() _update_command_table_from_extensions([], index_extensions) - elapsed_extensions = time.time() - start_time - print(f"[PERF] Loaded extensions in {elapsed_extensions:.3f} seconds", file=sys.stderr, flush=True) 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 @@ -520,25 +501,16 @@ def _get_extension_suppressions(mod_loaders): # No module found from the index. Load all command modules and extensions logger.debug("Loading all modules and extensions") - t1 = time.time() - print(f"[PERF] Loading ALL modules (no index)", file=sys.stderr, flush=True) _update_command_table_from_modules(args) - elapsed_all_modules = time.time() - t1 - print(f"[PERF] Loaded ALL modules in {elapsed_all_modules:.3f} seconds", file=sys.stderr, flush=True) - t1 = time.time() ext_suppressions = _get_extension_suppressions(self.loaders) # We always load extensions even if the appropriate module has been loaded # as an extension could override the commands already loaded. _update_command_table_from_extensions(ext_suppressions) - elapsed_all_extensions = time.time() - t1 - print(f"[PERF] Loaded ALL extensions in {elapsed_all_extensions:.3f} seconds", file=sys.stderr, flush=True) logger.debug("Loaded %d groups, %d commands.", len(self.command_group_table), len(self.command_table)) if use_command_index: - t1 = time.time() command_index.update(self.command_table) - print(f"[PERF] Command index update: {time.time()-t1:.3f}s", file=sys.stderr, flush=True) return self.command_table 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 b27910ae549..e5a39925a80 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -506,13 +506,6 @@ class AzCliCommandInvoker(CommandInvoker): # pylint: disable=too-many-statements,too-many-locals,too-many-branches def execute(self, args): - import time - import sys - import os - _start_time = time.time() - if os.environ.get('_ARGCOMPLETE'): - print(f"[PERF] Starting completion at {_start_time}", file=sys.stderr, flush=True) - from knack.events import (EVENT_INVOKER_PRE_CMD_TBL_CREATE, EVENT_INVOKER_POST_CMD_TBL_CREATE, EVENT_INVOKER_CMD_TBL_LOADED, EVENT_INVOKER_PRE_PARSE_ARGS, EVENT_INVOKER_POST_PARSE_ARGS, @@ -595,9 +588,6 @@ def execute(self, args): if args[0].lower() == 'help': args[0] = '--help' - if os.environ.get('_ARGCOMPLETE'): - elapsed = time.time() - _start_time - print(f"[PERF] About to enable autocomplete. Elapsed so far: {elapsed:.3f} seconds", file=sys.stderr, flush=True) self.parser.enable_autocomplete() self.cli_ctx.raise_event(EVENT_INVOKER_PRE_PARSE_ARGS, args=args) diff --git a/src/azure-cli/azure/cli/__main__.py b/src/azure-cli/azure/cli/__main__.py index 632a99fd3c9..fefbc13df17 100644 --- a/src/azure-cli/azure/cli/__main__.py +++ b/src/azure-cli/azure/cli/__main__.py @@ -9,12 +9,6 @@ start_time = timeit.default_timer() import sys -import os - -# Track completion performance - store start time for argcomplete to use -if os.environ.get('_ARGCOMPLETE'): - sys._cli_start_time = start_time - print(f"[PERF] az CLI entry point at {start_time}", file=sys.stderr, flush=True) from azure.cli.core import telemetry from azure.cli.core import get_default_cli @@ -35,10 +29,6 @@ def cli_main(cli, args): az_cli = get_default_cli() -if os.environ.get('_ARGCOMPLETE'): - elapsed = timeit.default_timer() - start_time - print(f"[PERF] CLI initialized in {elapsed:.3f} seconds", file=sys.stderr, flush=True) - telemetry.set_application(az_cli, ARGCOMPLETE_ENV_NAME) # Log the init finish time @@ -48,9 +38,6 @@ def cli_main(cli, args): try: telemetry.start() - if os.environ.get('_ARGCOMPLETE'): - print(f"[PERF] Calling cli_main() at {timeit.default_timer():.3f}", file=sys.stderr, flush=True) - exit_code = cli_main(az_cli, sys.argv[1:]) if exit_code == 0: @@ -68,13 +55,7 @@ def cli_main(cli, args): finally: # Log the invoke finish time invoke_finish_time = timeit.default_timer() - - if os.environ.get('_ARGCOMPLETE'): - total_time = invoke_finish_time - start_time - print(f"[PERF] ============================================", file=sys.stderr, flush=True) - print(f"[PERF] TOTAL TAB COMPLETION TIME: {total_time:.3f} seconds", file=sys.stderr, flush=True) - print(f"[PERF] ============================================", file=sys.stderr, flush=True) - + logger.info("Command ran in %.3f seconds (init: %.3f, invoke: %.3f)", invoke_finish_time - start_time, init_finish_time - start_time, From 5037d7f07252f95c9042120d8a5ab09a7b9ce7a6 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Wed, 7 Jan 2026 15:27:02 +1100 Subject: [PATCH 10/16] fix: cleanup --- src/azure-cli-core/azure/cli/core/__init__.py | 3 ++- src/azure-cli-core/azure/cli/core/commands/__init__.py | 1 - src/azure-cli/azure/cli/__main__.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 7b0e6c123f0..1145b196e24 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -612,8 +612,9 @@ def get(self, args): return None # Make sure the top-level command is provided, like `az version`. - # For top-level completion (az [tab]), use a special marker to skip module loading + # 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 a special marker so we know to skip module loading for top-level completion index = self.INDEX[self._COMMAND_INDEX] 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 e5a39925a80..696b6093f5d 100644 --- a/src/azure-cli-core/azure/cli/core/commands/__init__.py +++ b/src/azure-cli-core/azure/cli/core/commands/__init__.py @@ -519,7 +519,6 @@ def execute(self, args): args = _pre_command_table_create(self.cli_ctx, args) self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args) - # @TODO: this is one bottleneck self.commands_loader.load_command_table(args) self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, load_cmd_tbl_func=self.commands_loader.load_command_table, args=args) diff --git a/src/azure-cli/azure/cli/__main__.py b/src/azure-cli/azure/cli/__main__.py index fefbc13df17..befd36668a6 100644 --- a/src/azure-cli/azure/cli/__main__.py +++ b/src/azure-cli/azure/cli/__main__.py @@ -55,7 +55,6 @@ def cli_main(cli, args): finally: # Log the invoke finish time invoke_finish_time = timeit.default_timer() - logger.info("Command ran in %.3f seconds (init: %.3f, invoke: %.3f)", invoke_finish_time - start_time, init_finish_time - start_time, From 77441a6c88412db7ea89f8424a1d508459c7726c Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Thu, 8 Jan 2026 14:42:40 +1100 Subject: [PATCH 11/16] refactor: remove blank lines --- .vscode/launch.json | 4 ++-- src/azure-cli-core/azure/cli/core/__init__.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 82544dba4de..5d00ab3b09d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -49,8 +49,8 @@ "cwd": "${workspaceFolder}", "env": { "_ARGCOMPLETE": "1", - "COMP_LINE": "az ", - "COMP_POINT": "3", + "COMP_LINE": "az vm create --", + "COMP_POINT": "18", "_ARGCOMPLETE_SUPPRESS_SPACE": "0", "_ARGCOMPLETE_IFS": "\n", "_ARGCOMPLETE_SHELL": "powershell", diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 1145b196e24..297d55b5677 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -434,7 +434,6 @@ def _get_extension_suppressions(mod_loaders): index_result = command_index.get(args) if index_result: index_modules, index_extensions = index_result - # Special case for top-level completion - create minimal command groups if index_modules == '__top_level_completion__': from azure.cli.core.commands import AzCliCommand @@ -447,11 +446,11 @@ def _get_extension_suppressions(mod_loaders): self, cmd_name, lambda: None ) return self.command_table - + # 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) - + # The index won't contain suppressed extensions _update_command_table_from_extensions([], index_extensions) From d45fec1a9e10f42a116e79ba32441e24d550d33f Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 19 Jan 2026 14:30:52 +1100 Subject: [PATCH 12/16] refactor: create variable for top_level_marker --- src/azure-cli-core/azure/cli/core/__init__.py | 6 ++++-- 1 file changed, 4 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 297d55b5677..90d0d000fd5 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -26,6 +26,8 @@ EXCLUDED_PARAMS = ['self', 'raw', 'polling', 'custom_headers', 'operation_config', 'content_version', 'kwargs', 'client', 'no_wait'] 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__' # [Reserved, in case of future usage] # Modules that will always be loaded. They don't expose commands but hook into CLI core. @@ -435,7 +437,7 @@ def _get_extension_suppressions(mod_loaders): if index_result: index_modules, index_extensions = index_result # Special case for top-level completion - create minimal command groups - if index_modules == '__top_level_completion__': + if index_modules == TOP_LEVEL_COMPLETION_MARKER: from azure.cli.core.commands import AzCliCommand # index_extensions contains the command names, not extensions for cmd_name in index_extensions: @@ -619,7 +621,7 @@ def get(self, args): index = self.INDEX[self._COMMAND_INDEX] all_commands = list(index.keys()) logger.debug("Top-level completion: %d commands available", len(all_commands)) - return '__top_level_completion__', all_commands # special marker, command list + return TOP_LEVEL_COMPLETION_MARKER, all_commands # special marker, command list return None # Get the top-level command, like `network` in `network vnet create -h` From d1a0f27e130bc6ce5ea4d7cc1107d4df9172f94f Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 19 Jan 2026 15:25:58 +1100 Subject: [PATCH 13/16] refactor: put logic into separate methods --- src/azure-cli-core/azure/cli/core/__init__.py | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index 90d0d000fd5..74e16af5dba 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -210,6 +210,19 @@ def __init__(self, cli_ctx=None): self.cmd_to_loader_map = {} self.loaders = [] + def _create_stub_commands_for_completion(self, command_names): + """Create stub commands for top-level tab completion optimization. + + Stub commands allow argcomplete to parse command names without loading modules. + + :param command_names: List of command names to create stubs for + """ + from azure.cli.core.commands import AzCliCommand + for cmd_name in command_names: + if cmd_name not in self.command_table: + # Create stub with no-op handler - never invoked during completion + self.command_table[cmd_name] = AzCliCommand(self, cmd_name, lambda: None) + def _update_command_definitions(self): for cmd_name in self.command_table: loaders = self.cmd_to_loader_map[cmd_name] @@ -436,17 +449,9 @@ def _get_extension_suppressions(mod_loaders): index_result = command_index.get(args) if index_result: index_modules, index_extensions = index_result - # Special case for top-level completion - create minimal command groups + if index_modules == TOP_LEVEL_COMPLETION_MARKER: - from azure.cli.core.commands import AzCliCommand - # index_extensions contains the command names, not extensions - for cmd_name in index_extensions: - # Create a minimal command entry for tab completion - # This allows argparse to see the command without loading the module - if cmd_name not in self.command_table: - self.command_table[cmd_name] = AzCliCommand( - self, cmd_name, lambda: None - ) + self._create_stub_commands_for_completion(index_extensions) return self.command_table # Always load modules and extensions, because some of them (like those in @@ -500,7 +505,6 @@ def _get_extension_suppressions(mod_loaders): else: logger.debug("No module found from index for '%s'", args) - # No module found from the index. Load all command modules and extensions logger.debug("Loading all modules and extensions") _update_command_table_from_modules(args) @@ -596,6 +600,18 @@ def __init__(self, cli_ctx=None): self.cloud_profile = cli_ctx.cloud.profile self.cli_ctx = cli_ctx + def _get_top_level_completion_commands(self): + """Get all command names for top-level completion optimization. + + Returns marker and command list for creating stub commands without module loading. + + :return: tuple of (TOP_LEVEL_COMPLETION_MARKER, list of command names) + """ + index = self.INDEX[self._COMMAND_INDEX] + all_commands = list(index.keys()) + logger.debug("Top-level completion: %d commands available", len(all_commands)) + return TOP_LEVEL_COMPLETION_MARKER, all_commands + def get(self, args): """Get the corresponding module and extension list of a command. @@ -617,11 +633,7 @@ def get(self, args): 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 a special marker so we know to skip module loading for top-level completion - index = self.INDEX[self._COMMAND_INDEX] - all_commands = list(index.keys()) - logger.debug("Top-level completion: %d commands available", len(all_commands)) - return TOP_LEVEL_COMPLETION_MARKER, all_commands # special marker, command list + return self._get_top_level_completion_commands() return None # Get the top-level command, like `network` in `network vnet create -h` From 3e7a9452769e1ad45cb062ab43b666bb8d01b6ee Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 19 Jan 2026 16:41:05 +1100 Subject: [PATCH 14/16] refactor: address copilot comments --- src/azure-cli-core/azure/cli/core/__init__.py | 18 ++++++++++++++---- 1 file changed, 14 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 74e16af5dba..c407dde1bd2 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -218,10 +218,15 @@ def _create_stub_commands_for_completion(self, command_names): :param command_names: List of command names to create stubs for """ from azure.cli.core.commands import AzCliCommand + + def _stub_handler(*args, **kwargs): + """Stub command handler used only for argument completion.""" + return None + for cmd_name in command_names: if cmd_name not in self.command_table: - # Create stub with no-op handler - never invoked during completion - self.command_table[cmd_name] = AzCliCommand(self, cmd_name, lambda: None) + # Stub commands only need names for argcomplete parser construction. + self.command_table[cmd_name] = AzCliCommand(self, cmd_name, _stub_handler) def _update_command_definitions(self): for cmd_name in self.command_table: @@ -452,6 +457,7 @@ def _get_extension_suppressions(mod_loaders): if index_modules == TOP_LEVEL_COMPLETION_MARKER: self._create_stub_commands_for_completion(index_extensions) + _update_command_table_from_extensions([], ALWAYS_LOADED_EXTENSIONS) return self.command_table # Always load modules and extensions, because some of them (like those in @@ -604,10 +610,14 @@ def _get_top_level_completion_commands(self): """Get all command names for top-level completion optimization. Returns marker and command list for creating stub commands without module loading. + Returns None if index is empty, triggering fallback to full module loading. - :return: tuple of (TOP_LEVEL_COMPLETION_MARKER, list of command names) + :return: tuple of (TOP_LEVEL_COMPLETION_MARKER, list of command names) or None """ - index = self.INDEX[self._COMMAND_INDEX] + index = 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 all_commands = list(index.keys()) logger.debug("Top-level completion: %d commands available", len(all_commands)) return TOP_LEVEL_COMPLETION_MARKER, all_commands From 0d707ea9ab8437f13aa705314b7e982c78914391 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 19 Jan 2026 16:43:49 +1100 Subject: [PATCH 15/16] refactor: address copilot docstring comment --- src/azure-cli-core/azure/cli/core/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/azure-cli-core/azure/cli/core/__init__.py b/src/azure-cli-core/azure/cli/core/__init__.py index c407dde1bd2..704601aeaad 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -607,20 +607,21 @@ def __init__(self, cli_ctx=None): self.cli_ctx = cli_ctx def _get_top_level_completion_commands(self): - """Get all command names for top-level completion optimization. + """Get top-level command names for tab completion optimization. - Returns marker and command list for creating stub commands without module loading. - Returns None if index is empty, triggering fallback to full module loading. + Returns marker and list of top-level commands (e.g., 'network', 'vm') for creating + stub commands without module loading. Returns None if index is empty, triggering + fallback to full module loading. - :return: tuple of (TOP_LEVEL_COMPLETION_MARKER, list of command names) or None + :return: tuple of (TOP_LEVEL_COMPLETION_MARKER, list of top-level command names) or None """ index = 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 - all_commands = list(index.keys()) - logger.debug("Top-level completion: %d commands available", len(all_commands)) - return TOP_LEVEL_COMPLETION_MARKER, all_commands + top_level_commands = list(index.keys()) + logger.debug("Top-level completion: %d commands available", len(top_level_commands)) + return TOP_LEVEL_COMPLETION_MARKER, top_level_commands def get(self, args): """Get the corresponding module and extension list of a command. From 6490a2737a1a0293b6248d7bef055f35d5bd0b38 Mon Sep 17 00:00:00 2001 From: Daniel Languiller Date: Mon, 19 Jan 2026 16:53:30 +1100 Subject: [PATCH 16/16] refactor: address flake whitespace errors --- src/azure-cli-core/azure/cli/core/__init__.py | 16 ++++++++-------- 1 file changed, 8 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 704601aeaad..cda72b55ec4 100644 --- a/src/azure-cli-core/azure/cli/core/__init__.py +++ b/src/azure-cli-core/azure/cli/core/__init__.py @@ -212,17 +212,17 @@ def __init__(self, cli_ctx=None): def _create_stub_commands_for_completion(self, command_names): """Create stub commands for top-level tab completion optimization. - + Stub commands allow argcomplete to parse command names without loading modules. - + :param command_names: List of command names to create stubs for """ from azure.cli.core.commands import AzCliCommand - + def _stub_handler(*args, **kwargs): """Stub command handler used only for argument completion.""" return None - + for cmd_name in command_names: if cmd_name not in self.command_table: # Stub commands only need names for argcomplete parser construction. @@ -608,11 +608,11 @@ def __init__(self, cli_ctx=None): def _get_top_level_completion_commands(self): """Get top-level command names for tab completion optimization. - - Returns marker and list of top-level commands (e.g., 'network', 'vm') for creating - stub commands without module loading. Returns None if index is empty, triggering + + Returns marker and list of top-level commands (e.g., 'network', 'vm') for creating + stub commands without module loading. Returns None if index is empty, triggering fallback to full module loading. - + :return: tuple of (TOP_LEVEL_COMPLETION_MARKER, list of top-level command names) or None """ index = self.INDEX.get(self._COMMAND_INDEX) or {}