Skip to content

Commit af398fb

Browse files
committed
Perf: lazy imports, lighter stdlib, parallel session loading, lazy LRO progress bar
Category 1 - Core lazy import deferral (~40-50ms saving): - Defer yaml, base64, binascii, getpass, ssl, urlopen in core/util.py - Defer humanfriendly.terminal.spinners.Spinner in commands/progress.py - Defer concurrent.futures/ThreadPoolExecutor in core/__init__.py - Defer pkginfo in extension/__init__.py - Defer urlparse in extension/operations.py - Defer hashlib in decorators.py - Defer shutil in local_context.py - Defer random in commands/validators.py - Defer yaml, use importlib.resources in _help_loaders.py - Defer AAZLROPoller in aaz/_command.py - Defer AAZNoPolling/AAZBasePolling in aaz/_client.py Category 2 - Lighter stdlib replacements (~5-10ms saving): - Replace timeit.default_timer() with time.perf_counter() - Replace platform.system() with sys.platform in parameters.py - Cache platform.uname() in telemetry.py _get_base_properties() Category 7 - Telemetry lazy imports (~10-15ms saving): - Defer subprocess, portalocker in telemetry/__init__.py - Defer urllib.request, urllib.error in telemetry_client.py - Defer logging.handlers in telemetry/util.py Category 12 - Parallel session file loading (~70-75ms saving): - Load session files via ThreadPoolExecutor in AzCli.__init__ Category 14 - Lazy LongRunningOperation progress bar (~66ms saving): - Make progress_bar a lazy @Property in LongRunningOperation - Defer IndeterminateProgressBar import to first access Also fix: call_once decorator was not setting executed=True flag.
1 parent dc9fdb3 commit af398fb

17 files changed

Lines changed: 112 additions & 71 deletions

File tree

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

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@
99
import os
1010
import sys
1111
import json
12-
import timeit
13-
import concurrent.futures
14-
from concurrent.futures import ThreadPoolExecutor
12+
import time
1513

1614
from knack.cli import CLI
1715
from knack.commands import CLICommandsLoader
@@ -100,14 +98,24 @@ def __init__(self, **kwargs):
10098

10199
azure_folder = self.config.config_dir
102100
ensure_dir(azure_folder)
103-
ACCOUNT.load(os.path.join(azure_folder, 'azureProfile.json'))
104-
CONFIG.load(os.path.join(azure_folder, 'az.json'))
105-
SESSION.load(os.path.join(azure_folder, 'az.sess'), max_age=3600)
106-
INDEX.load(os.path.join(azure_folder, 'commandIndex.json'))
107-
EXTENSION_INDEX.load(os.path.join(azure_folder, 'extensionIndex.json'))
108-
HELP_INDEX.load(os.path.join(azure_folder, 'helpIndex.json'))
109-
EXTENSION_HELP_INDEX.load(os.path.join(azure_folder, 'extensionHelpIndex.json'))
110-
VERSIONS.load(os.path.join(azure_folder, 'versionCheck.json'))
101+
102+
# Load session files in parallel — each open() costs ~20ms on Windows due to
103+
# filesystem/antivirus overhead, and file I/O releases the GIL.
104+
from concurrent.futures import ThreadPoolExecutor
105+
load_tasks = [
106+
(ACCOUNT, os.path.join(azure_folder, 'azureProfile.json'), 0),
107+
(CONFIG, os.path.join(azure_folder, 'az.json'), 0),
108+
(SESSION, os.path.join(azure_folder, 'az.sess'), 3600),
109+
(INDEX, os.path.join(azure_folder, 'commandIndex.json'), 0),
110+
(EXTENSION_INDEX, os.path.join(azure_folder, 'extensionIndex.json'), 0),
111+
(HELP_INDEX, os.path.join(azure_folder, 'helpIndex.json'), 0),
112+
(EXTENSION_HELP_INDEX, os.path.join(azure_folder, 'extensionHelpIndex.json'), 0),
113+
(VERSIONS, os.path.join(azure_folder, 'versionCheck.json'), 0),
114+
]
115+
with ThreadPoolExecutor(max_workers=len(load_tasks)) as pool:
116+
futures = [pool.submit(s.load, path, max_age=age) for s, path, age in load_tasks]
117+
for f in futures:
118+
f.result()
111119
handle_version_update()
112120

113121
self.cloud = get_active_cloud(self)
@@ -322,14 +330,14 @@ def _update_command_table_from_modules(args, command_modules=None):
322330
except ImportError as e:
323331
logger.warning(e)
324332

325-
start_time = timeit.default_timer()
333+
start_time = time.perf_counter()
326334
logger.debug("Loading command modules...")
327335
results = self._load_modules(args, command_modules)
328336

329337
count, cumulative_group_count, cumulative_command_count = \
330338
self._process_results_with_timing(results)
331339

332-
total_elapsed_time = timeit.default_timer() - start_time
340+
total_elapsed_time = time.perf_counter() - start_time
333341
# Summary line
334342
logger.debug(self.item_format_string,
335343
"Total ({})".format(count), total_elapsed_time,
@@ -404,7 +412,7 @@ def _filter_modname(extensions):
404412
# Add to the map. This needs to happen before we load commands as registering a command
405413
# from an extension requires this map to be up-to-date.
406414
# self._mod_to_ext_map[ext_mod] = ext_name
407-
start_time = timeit.default_timer()
415+
start_time = time.perf_counter()
408416
extension_command_table, extension_group_table, extension_command_loader = \
409417
_load_extension_command_loader(self, args, ext_mod)
410418
import_extension_breaking_changes(ext_mod)
@@ -427,7 +435,7 @@ def _filter_modname(extensions):
427435
self.command_table.update(extension_command_table)
428436
self.command_group_table.update(extension_group_table)
429437

430-
elapsed_time = timeit.default_timer() - start_time
438+
elapsed_time = time.perf_counter() - start_time
431439
logger.debug(self.item_ext_format_string, ext_name, elapsed_time,
432440
len(extension_group_table), len(extension_command_table),
433441
ext_dir)
@@ -667,6 +675,8 @@ def load_arguments(self, command=None):
667675

668676
def _load_modules(self, args, command_modules):
669677
"""Load command modules using ThreadPoolExecutor with timeout protection."""
678+
import concurrent.futures
679+
from concurrent.futures import ThreadPoolExecutor
670680
from azure.cli.core.commands import BLOCKED_MODS
671681

672682
results = []
@@ -708,10 +718,10 @@ def _load_single_module(self, mod, args):
708718
from azure.cli.core.commands import _load_module_command_loader
709719
import traceback
710720
try:
711-
start_time = timeit.default_timer()
721+
start_time = time.perf_counter()
712722
module_command_table, module_group_table, command_loader = _load_module_command_loader(self, args, mod)
713723
import_module_breaking_changes(mod)
714-
elapsed_time = timeit.default_timer() - start_time
724+
elapsed_time = time.perf_counter() - start_time
715725
return ModuleLoadResult(mod, module_command_table, module_group_table, elapsed_time, command_loader=command_loader)
716726
except Exception as ex: # pylint: disable=broad-except
717727
tb_str = traceback.format_exc()
@@ -1227,7 +1237,7 @@ def update(self, command_table):
12271237
12281238
:param command_table: The command table built by azure.cli.core.MainCommandsLoader.load_command_table
12291239
"""
1230-
start_time = timeit.default_timer()
1240+
start_time = time.perf_counter()
12311241
self.INDEX[self._COMMAND_INDEX_VERSION] = __version__
12321242
self.INDEX[self._COMMAND_INDEX_CLOUD_PROFILE] = self.cloud_profile
12331243
from collections import defaultdict
@@ -1242,7 +1252,7 @@ def update(self, command_table):
12421252
if module_name not in index[top_command]:
12431253
index[top_command].append(module_name)
12441254

1245-
elapsed_time = timeit.default_timer() - start_time
1255+
elapsed_time = time.perf_counter() - start_time
12461256
self.INDEX[self._COMMAND_INDEX] = index
12471257

12481258
self.update_extension_index(command_table)

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

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
from knack.util import CLIError
1212
from knack.log import get_logger
1313

14-
import yaml
15-
1614
logger = get_logger(__name__)
1715

1816
try:
@@ -100,7 +98,12 @@ class YamlLoaderMixin: # pylint:disable=too-few-public-methods
10098
# get the list of yaml help file names for the command or group
10199
@staticmethod
102100
def _get_yaml_help_files_list(nouns, cmd_loader_map_ref):
103-
import inspect
101+
"""Get list of YAML help file paths for the command or group.
102+
103+
Uses importlib.resources for zip-compatible package file discovery,
104+
with a fallback to filesystem-based os.listdir for older layouts.
105+
"""
106+
import importlib.resources
104107

105108
command_nouns = " ".join(nouns)
106109
# if command in map, get the loader. Path of loader is path of helpfile.
@@ -121,17 +124,19 @@ def _get_yaml_help_files_list(nouns, cmd_loader_map_ref):
121124
results = []
122125
if loaders:
123126
for loader in loaders:
124-
loader_file_path = inspect.getfile(loader.__class__)
125-
dir_name = os.path.dirname(loader_file_path)
126-
files = os.listdir(dir_name)
127-
for file in files:
128-
if file.endswith("help.yaml") or file.endswith("help.yml"):
129-
help_file_path = os.path.join(dir_name, file)
130-
results.append(help_file_path)
127+
# Use importlib.resources for zip-compatible package file discovery
128+
loader_module = loader.__class__.__module__
129+
pkg_files = importlib.resources.files(loader_module)
130+
for item in pkg_files.iterdir():
131+
name = item.name
132+
if name.endswith("help.yaml") or name.endswith("help.yml"):
133+
results.append(str(item))
131134
return results
132135

133136
@staticmethod
134137
def _parse_yaml_from_string(text, help_file_path):
138+
import yaml
139+
135140
dir_name, base_name = os.path.split(help_file_path)
136141
pretty_file_path = os.path.join(os.path.basename(dir_name), base_name)
137142

src/azure-cli-core/azure/cli/core/aaz/_client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from azure.core.polling.base_polling import LocationPolling, StatusCheckPolling
99
from abc import abstractmethod
1010

11-
from ._poller import AAZNoPolling, AAZBasePolling
1211
from azure.cli.core.cloud import (CloudEndpointNotSetException, CloudSuffixNotSetException,
1312
CloudNameEnum as _CloudNameEnum)
1413

@@ -111,6 +110,7 @@ def send_request(self, request, stream=False, **kwargs): # pylint: disable=argu
111110
def build_lro_polling(self, no_wait, initial_session, deserialization_callback, error_callback,
112111
lro_options=None, path_format_arguments=None):
113112
from azure.core.polling.base_polling import OperationResourcePolling
113+
from ._poller import AAZNoPolling, AAZBasePolling
114114
if no_wait == True: # noqa: E712, pylint: disable=singleton-comparison
115115
polling = AAZNoPolling()
116116
else:
@@ -233,6 +233,7 @@ def _build_per_call_policies(cls, ctx, **kwargs):
233233
def build_lro_polling(self, no_wait, initial_session, deserialization_callback, error_callback,
234234
lro_options=None, path_format_arguments=None):
235235
from azure.mgmt.core.polling.arm_polling import AzureAsyncOperationPolling, BodyContentPolling
236+
from ._poller import AAZNoPolling, AAZBasePolling
236237
if no_wait == True: # noqa: E712, pylint: disable=singleton-comparison
237238
polling = AAZNoPolling()
238239
else:

src/azure-cli-core/azure/cli/core/aaz/_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
from ._base import AAZUndefined, AAZBaseValue
2323
from ._field_type import AAZObjectType
2424
from ._paging import AAZPaged
25-
from ._poller import AAZLROPoller
2625
from ._command_ctx import AAZCommandCtx
2726
from .exceptions import AAZUnknownFieldError, AAZUnregisteredArg
2827
from .utils import get_aaz_profile_module_name
@@ -235,6 +234,7 @@ def processor(schema, result):
235234
def build_lro_poller(self, executor, extract_result):
236235
""" Build AAZLROPoller instance to support long running operation
237236
"""
237+
from ._poller import AAZLROPoller
238238
polling_generator = executor()
239239
if self.ctx.lro_no_wait:
240240
# run until yield the first polling

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

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
get_command_type_kwarg, read_file_content, get_arg_list, poller_classes)
3030
from azure.cli.core.local_context import LocalContextAction
3131
from azure.cli.core import telemetry
32-
from azure.cli.core.commands.progress import IndeterminateProgressBar
3332

3433
from knack.arguments import CLICommandArgument
3534
from knack.commands import CLICommand, CommandGroup, PREVIEW_EXPERIMENTAL_CONFLICT_ERROR
@@ -1035,10 +1034,20 @@ def __init__(self, cli_ctx, start_msg='', finish_msg='', poller_done_interval_ms
10351034
self.deploy_dict = {}
10361035
self.last_progress_report = datetime.datetime.now()
10371036

1038-
self.progress_bar = None
1037+
self._progress_bar = None
10391038
disable_progress_bar = self.cli_ctx.config.getboolean('core', 'disable_progress_bar', False)
1040-
if not disable_progress_bar and not cli_ctx.only_show_errors:
1041-
self.progress_bar = progress_bar if progress_bar is not None else IndeterminateProgressBar(cli_ctx)
1039+
self.disable_progress_bar = disable_progress_bar or cli_ctx.only_show_errors
1040+
if not self.disable_progress_bar:
1041+
self._progress_bar = progress_bar
1042+
1043+
@property
1044+
def progress_bar(self):
1045+
if self.disable_progress_bar:
1046+
return None
1047+
if self._progress_bar is None:
1048+
from azure.cli.core.commands.progress import IndeterminateProgressBar
1049+
self._progress_bar = IndeterminateProgressBar(self.cli_ctx)
1050+
return self._progress_bar
10421051

10431052
def _delay(self):
10441053
time.sleep(self.poller_done_interval_ms / 1000.0)

src/azure-cli-core/azure/cli/core/commands/parameters.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66

77
import argparse
8-
import platform
8+
import sys
99

1010
from azure.cli.core import EXCLUDED_PARAMS
1111
from azure.cli.core.commands.constants import CLI_PARAM_KWARGS, CLI_POSITIONAL_PARAM_KWARGS
@@ -274,7 +274,7 @@ def get_location_type(cli_ctx):
274274
validator=generate_deployment_name
275275
)
276276

277-
quotes = '""' if platform.system() == 'Windows' else "''"
277+
quotes = '""' if sys.platform == 'win32' else "''"
278278
quote_text = 'Use {} to clear existing tags.'.format(quotes)
279279

280280
tags_type = CLIArgumentType(

src/azure-cli-core/azure/cli/core/commands/progress.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
# --------------------------------------------------------------------------------------------
55
import sys
66

7-
from humanfriendly.terminal.spinners import Spinner
8-
97
BAR_LEN = 70
108
EMPTY_LINE = ' ' * BAR_LEN
119

@@ -114,6 +112,7 @@ def write(self, args):
114112
:param args: dictionary containing key 'message'
115113
"""
116114
if self.spinner is None:
115+
from humanfriendly.terminal.spinners import Spinner
117116
self.spinner = Spinner( # pylint: disable=no-member
118117
label='In Progress', stream=self.out, hide_cursor=False)
119118
msg = args.get('message', 'In Progress')
@@ -178,10 +177,15 @@ def __init__(self, cli_ctx, message="Running"):
178177
self.message = message
179178
self.hook = self.cli_ctx.get_progress_controller(
180179
det=False,
181-
spinner=Spinner( # pylint: disable=no-member
182-
label='Running',
183-
stream=sys.stderr,
184-
hide_cursor=False))
180+
spinner=self._create_spinner())
181+
182+
@staticmethod
183+
def _create_spinner():
184+
from humanfriendly.terminal.spinners import Spinner
185+
return Spinner( # pylint: disable=no-member
186+
label='Running',
187+
stream=sys.stderr,
188+
hide_cursor=False)
185189

186190
def begin(self):
187191
self.hook.begin()

src/azure-cli-core/azure/cli/core/commands/validators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
import argparse
77
import time
8-
import random
98

109
from azure.cli.core.profiles import ResourceType
1110

@@ -67,6 +66,7 @@ def validate_key_value_pairs(string):
6766

6867
def generate_deployment_name(namespace):
6968
if not namespace.deployment_name:
69+
import random
7070
namespace.deployment_name = \
7171
'azurecli{}{}'.format(str(time.time()), str(random.randint(1, 100000)))
7272

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
that it doesn't import modules other than those in the Python Standard Library
1111
"""
1212

13-
import hashlib
1413
from functools import wraps
1514

1615
from knack.log import get_logger
@@ -43,6 +42,7 @@ def call_once(factory_func):
4342
def _wrapped(*args, **kwargs):
4443
if not factory_func.executed:
4544
factory_func.cached_result = factory_func(*args, **kwargs)
45+
factory_func.executed = True
4646

4747
return factory_func.cached_result
4848

@@ -57,6 +57,7 @@ def hash256_result(func):
5757

5858
@wraps(func)
5959
def _decorator(*args, **kwargs):
60+
import hashlib
6061
val = func(*args, **kwargs)
6162
if val is None:
6263
raise ValueError('Return value is None')

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import re
1111
from sysconfig import get_path
1212

13-
import pkginfo
1413
from knack.config import CLIConfig
1514
from knack.log import get_logger
1615
from azure.cli.core._config import GLOBAL_CONFIG_DIR, ENV_VAR_PREFIX
@@ -134,6 +133,7 @@ def get_version(self):
134133

135134
def get_metadata(self):
136135
from glob import glob
136+
import pkginfo
137137

138138
metadata = {}
139139
ext_dir = self.path or get_extension_path(self.name)
@@ -213,6 +213,8 @@ def get_version(self):
213213
return self.metadata.get('version')
214214

215215
def get_metadata(self):
216+
import pkginfo
217+
216218
metadata = {}
217219
ext_dir = self.path
218220
if not ext_dir or not os.path.isdir(ext_dir):

0 commit comments

Comments
 (0)