Skip to content

Commit e2f8fbf

Browse files
authored
{Monitor} az monitor bar foo --help: Args-guided AAZ command tree loading for monitor module (#33024)
1 parent 3cd5496 commit e2f8fbf

File tree

382 files changed

+66391
-1952
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

382 files changed

+66391
-1952
lines changed

.flake8

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ exclude =
2828
vendored_sdks
2929
tests
3030
*/command_modules/*/aaz
31+
*/_legacy

pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[MASTER]
2-
ignore=tests,generated,vendored_sdks,privates
2+
ignore=tests,generated,vendored_sdks,privates,_legacy
33
ignore-patterns=test.*,azure_devops_build.*
44
ignore-paths=.*/command_modules/.*/aaz
55
reports=no

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
AAZPaginationTokenArgFormat
2222
from ._base import has_value, AAZValuePatch, AAZUndefined
2323
from ._command import AAZCommand, AAZWaitCommand, AAZCommandGroup, \
24-
register_callback, register_command, register_command_group, load_aaz_command_table, link_helper
24+
register_callback, register_command, register_command_group, load_aaz_command_table, \
25+
load_aaz_command_table_args_guided, link_helper
2526
from ._field_type import AAZIntType, AAZFloatType, AAZStrType, AAZBoolType, AAZDictType, AAZFreeFormDictType, \
2627
AAZListType, AAZObjectType, AAZIdentityObjectType, AAZAnyType
2728
from ._operation import AAZHttpOperation, AAZJsonInstanceUpdateOperation, AAZGenericInstanceUpdateOperation, \

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

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,181 @@ def decorator(cls):
387387
AAZ_PACKAGE_FULL_LOAD_ENV_NAME = 'AZURE_AAZ_FULL_LOAD'
388388

389389

390+
def load_aaz_command_table_args_guided(loader, aaz_pkg_name, args):
391+
"""Args-guided AAZ command tree loader.
392+
393+
Instead of importing the entire AAZ package tree (all __init__.py files which eagerly
394+
import all command classes), this function navigates only to the relevant subtree based
395+
on CLI args. For example, ``az monitor log-analytics workspace create --help`` only loads
396+
the ``workspace`` sub-package and the ``_create`` module, skipping all other commands.
397+
398+
This requires that AAZ ``__init__.py`` files do NOT contain wildcard imports
399+
(``from ._create import *`` etc.) -- they should be empty (just the license header).
400+
"""
401+
profile_pkg = _get_profile_pkg(aaz_pkg_name, loader.cli_ctx.cloud)
402+
403+
command_table = {}
404+
command_group_table = {}
405+
if args is None or os.environ.get(AAZ_PACKAGE_FULL_LOAD_ENV_NAME, 'False').lower() == 'true':
406+
effective_args = None # fully load
407+
else:
408+
effective_args = list(args)
409+
if profile_pkg is not None:
410+
_load_aaz_by_pkg(loader, profile_pkg, effective_args,
411+
command_table, command_group_table)
412+
413+
for group_name, command_group in command_group_table.items():
414+
loader.command_group_table[group_name] = command_group
415+
for command_name, command in command_table.items():
416+
loader.command_table[command_name] = command
417+
return command_table, command_group_table
418+
419+
420+
def _try_import_module(relative_name, package):
421+
"""Try to import a module by relative name, return None on failure."""
422+
try:
423+
return importlib.import_module(relative_name, package)
424+
except ModuleNotFoundError as ex:
425+
# Only treat "module not found" for the requested module as a benign miss.
426+
target_mod_name = f"{package}.{relative_name.lstrip('.')}"
427+
if ex.name == target_mod_name:
428+
return None
429+
# Different module is missing; propagate so the real error surfaces.
430+
raise
431+
except ImportError:
432+
logger.error("Error importing module %r from package %r", relative_name, package)
433+
raise
434+
435+
436+
def _register_from_module(loader, mod, command_table, command_group_table):
437+
"""Scan a module's namespace for AAZCommand/AAZCommandGroup classes and register them."""
438+
for value in mod.__dict__.values():
439+
if not isinstance(value, type):
440+
continue
441+
if value.__module__ != mod.__name__: # skip imported classes
442+
continue
443+
if issubclass(value, AAZCommandGroup) and value.AZ_NAME:
444+
command_group_table[value.AZ_NAME] = value(cli_ctx=loader.cli_ctx)
445+
elif issubclass(value, AAZCommand) and value.AZ_NAME:
446+
command_table[value.AZ_NAME] = value(loader=loader)
447+
448+
449+
def _get_pkg_children(pkg):
450+
"""List child entries of a package using pkgutil.
451+
452+
Returns two sets: (file_stems, subdir_names).
453+
- file_stems: module-like stems, e.g. {'_create', '_list', '__cmd_group'}
454+
- subdir_names: sub-package directory names, e.g. {'namespace', 'eventhub'}
455+
"""
456+
import pkgutil
457+
file_stems = set()
458+
subdir_names = set()
459+
460+
pkg_path = getattr(pkg, '__path__', None)
461+
if not pkg_path:
462+
return file_stems, subdir_names
463+
464+
for _importer, name, ispkg in pkgutil.iter_modules(pkg_path):
465+
if ispkg:
466+
if not name.startswith('_'):
467+
subdir_names.add(name)
468+
else:
469+
file_stems.add(name)
470+
471+
return file_stems, subdir_names
472+
473+
474+
def _load_aaz_by_pkg(loader, pkg, args, command_table, command_group_table):
475+
"""Recursively navigate the AAZ package tree guided by CLI args.
476+
477+
- args is None -> full recursive load of all commands under this package.
478+
- args is empty list -> args exhausted; load current level's commands and sub-group headers.
479+
- args has items -> try to match first arg as a command module or sub-package,
480+
recurse with remaining args on match.
481+
- no match on first arg -> load current level's commands and sub-group headers.
482+
"""
483+
base_module = pkg.__name__
484+
file_stems, subdir_names = _get_pkg_children(pkg)
485+
486+
if args is not None and args and not args[0].startswith('-'):
487+
first_arg = args[0].lower().replace('-', '_')
488+
489+
# First arg matches a command module (e.g. "create" -> "_create")
490+
if f"_{first_arg}" in file_stems:
491+
mod = _try_import_module(f"._{first_arg}", base_module)
492+
if mod:
493+
_register_from_module(loader, mod, command_table, command_group_table)
494+
return
495+
496+
# First arg matches a sub-package (command group)
497+
if first_arg in subdir_names:
498+
sub_module = f"{base_module}.{first_arg}"
499+
mod = _try_import_module('.__cmd_group', sub_module)
500+
if mod:
501+
_register_from_module(loader, mod, command_table, command_group_table)
502+
sub_pkg = _try_import_module(f'.{first_arg}', base_module)
503+
if sub_pkg:
504+
_load_aaz_by_pkg(loader, sub_pkg, args[1:], command_table, command_group_table)
505+
return
506+
507+
# Load __cmd_group + all command modules at this level
508+
mod = _try_import_module('.__cmd_group', base_module)
509+
if mod:
510+
_register_from_module(loader, mod, command_table, command_group_table)
511+
512+
for stem in file_stems:
513+
if stem.startswith('_') and not stem.startswith('__'):
514+
mod = _try_import_module(f'.{stem}', base_module)
515+
if mod:
516+
_register_from_module(loader, mod, command_table, command_group_table)
517+
518+
for subdir in subdir_names:
519+
sub_module = f"{base_module}.{subdir}"
520+
if args is None:
521+
# Full load -> recurse into every sub-package
522+
sub_pkg = _try_import_module(f'.{subdir}', base_module)
523+
if sub_pkg:
524+
_load_aaz_by_pkg(loader, sub_pkg, None, command_table, command_group_table)
525+
else:
526+
# Args exhausted / not matched -> load sub-group header and the first
527+
# command so the group is non-empty and the parser creates a subparser
528+
# for it (required for help output).
529+
# TODO: After optimized loading is applied to the whole CLI, revisit
530+
# this and consider a lighter approach (e.g. parser-level fix) to
531+
# avoid importing one command per trimmed sub-group.
532+
mod = _try_import_module('.__cmd_group', sub_module)
533+
if mod:
534+
_register_from_module(loader, mod, command_table, command_group_table)
535+
sub_pkg = _try_import_module(f'.{subdir}', base_module)
536+
if sub_pkg:
537+
_load_first_command(loader, sub_pkg, command_table)
538+
539+
540+
def _load_first_command(loader, pkg, command_table):
541+
"""Load the first available command module from a package.
542+
543+
This ensures the command group is non-empty so the parser creates a subparser
544+
for it, which is required for it to appear in help output.
545+
"""
546+
file_stems, subdir_names = _get_pkg_children(pkg)
547+
base_module = pkg.__name__
548+
549+
# Try to load a command module at this level first
550+
for stem in sorted(file_stems):
551+
if stem.startswith('_') and not stem.startswith('__'):
552+
mod = _try_import_module(f'.{stem}', base_module)
553+
if mod:
554+
_register_from_module(loader, mod, command_table, {})
555+
return
556+
557+
# No command at this level, recurse into the first sub-package
558+
for subdir in sorted(subdir_names):
559+
sub_pkg = _try_import_module(f'.{subdir}', base_module)
560+
if sub_pkg:
561+
_load_first_command(loader, sub_pkg, command_table)
562+
return
563+
564+
390565
def load_aaz_command_table(loader, aaz_pkg_name, args):
391566
""" This function is used in AzCommandsLoader.load_command_table.
392567
It will load commands in module's aaz package.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Monitor Module: Legacy Fallback
2+
3+
## Overview
4+
5+
The monitor module ships a vendored copy of the pre-refactor code in `_legacy/`. Users can switch to this snapshot via config if the refactored implementation causes issues:
6+
7+
```bash
8+
az config set monitor.use_legacy=true # enable legacy mode
9+
az config set monitor.use_legacy=false # switch back to new mode (default)
10+
```
11+
12+
A warning is logged each time legacy mode is active.
13+
14+
## How It Works
15+
16+
In `__init__.py`, `MonitorCommandsLoader` reads the `monitor.use_legacy` config (default `false`):
17+
18+
- **New mode** — loads from `aaz/`, `operations/`, and `commands.py` using `load_aaz_command_table_args_guided`.
19+
- **Legacy mode** — loads from `_legacy/aaz/`, `_legacy/commands.py` using `load_aaz_command_table`. Arguments come from `_legacy/_params.py`.
20+
21+
The `_legacy/` folder is a frozen snapshot extracted from the `dev` branch. All absolute imports were rewritten from `azure.cli.command_modules.monitor.` to `azure.cli.command_modules.monitor._legacy.`.
22+
23+
## Known Adjustments
24+
25+
- **`_legacy/_params.py`**: Removed `monitor metrics alert update` argument registrations (lines for `add_actions`, `remove_actions`, `add_conditions`, `remove_conditions`) because the AAZ `MetricsAlertUpdate._build_arguments_schema` already defines them, and the old-style `action=MetricAlertAddAction` overrides corrupt AAZ argument parsing.
26+
- **Tests**: `test_monitor_general_operations.py` mocks `gen_guid` at both `azure.cli.command_modules.monitor.operations.monitor_clone_util` and `azure.cli.command_modules.monitor._legacy.operations.monitor_clone_util` so tests pass in either mode.
27+
- **Linting**: `_legacy/` is excluded via `pylintrc` (`ignore` list) and `.flake8` (`exclude` list).
28+
29+
## Dropping Legacy Support
30+
31+
When legacy mode is no longer needed:
32+
33+
1. **Delete the `_legacy/` folder**:
34+
```bash
35+
rm -rf src/azure-cli/azure/cli/command_modules/monitor/_legacy/
36+
```
37+
38+
2. **Simplify `__init__.py`** — remove `_use_legacy`, `_load_legacy_command_table`, and the dispatch in `load_command_table` / `load_arguments`. Inline `_load_new_command_table` as the sole `load_command_table`:
39+
```python
40+
# Remove these
41+
_CONFIG_SECTION = 'monitor'
42+
_USE_LEGACY_CONFIG_KEY = 'use_legacy'
43+
self._use_legacy = ...
44+
def _load_legacy_command_table(self, args): ...
45+
46+
# Keep only _load_new_command_table logic directly in load_command_table
47+
```
48+
49+
3. **Clean up tests** — remove the second `mock.patch` line for `_legacy` in `test_monitor_general_operations.py`:
50+
```python
51+
# Remove this line from each mock.patch block:
52+
mock.patch('azure.cli.command_modules.monitor._legacy.operations.monitor_clone_util.gen_guid', ...)
53+
```
54+
55+
4. **Revert linter config** — remove `_legacy` from `pylintrc` `ignore` and `*/_legacy` from `.flake8` `exclude`.

src/azure-cli/azure/cli/command_modules/monitor/__init__.py

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@
33
# Licensed under the MIT License. See License.txt in the project root for license information.
44
# --------------------------------------------------------------------------------------------
55

6+
from knack.log import get_logger
7+
68
from azure.cli.core import AzCommandsLoader
79
from azure.cli.core.commands import AzArgumentContext, CliCommandType
10+
from azure.cli.command_modules.monitor._help import helps # pylint: disable=unused-import
11+
12+
logger = get_logger(__name__)
813

9-
from azure.cli.command_modules.monitor._help import helps # pylint: disable=unused-import
14+
_CONFIG_SECTION = 'monitor'
15+
_USE_LEGACY_CONFIG_KEY = 'use_legacy'
1016

1117

1218
# pylint: disable=line-too-long
@@ -33,31 +39,84 @@ class MonitorCommandsLoader(AzCommandsLoader):
3339

3440
def __init__(self, cli_ctx=None):
3541
from azure.cli.core.profiles import ResourceType
36-
monitor_custom = CliCommandType(
37-
operations_tmpl='azure.cli.command_modules.monitor.custom#{}')
42+
self._use_legacy = cli_ctx.config.getboolean(
43+
_CONFIG_SECTION, _USE_LEGACY_CONFIG_KEY, fallback=False) if cli_ctx else False
44+
if self._use_legacy:
45+
monitor_custom = CliCommandType(
46+
operations_tmpl='azure.cli.command_modules.monitor._legacy.custom#{}')
47+
else:
48+
monitor_custom = CliCommandType(
49+
operations_tmpl='azure.cli.command_modules.monitor.custom#{}')
3850
super().__init__(cli_ctx=cli_ctx,
3951
resource_type=ResourceType.MGMT_MONITOR,
4052
argument_context_cls=MonitorArgumentContext,
4153
custom_command_type=monitor_custom)
4254

4355
def load_command_table(self, args):
44-
from azure.cli.command_modules.monitor.commands import load_command_table
56+
if self._use_legacy:
57+
return self._load_legacy_command_table(args)
58+
return self._load_new_command_table(args)
59+
60+
def _load_legacy_command_table(self, args):
61+
"""Load commands from the vendored _legacy snapshot (pre-refactor code from dev branch)."""
4562
from azure.cli.core.aaz import load_aaz_command_table
63+
64+
logger.warning(
65+
"The monitor module is using legacy mode. "
66+
"To switch to the new optimized implementation, run: "
67+
"az config set %s.%s=false",
68+
_CONFIG_SECTION, _USE_LEGACY_CONFIG_KEY)
69+
70+
try:
71+
from ._legacy import aaz as legacy_aaz
72+
except ImportError:
73+
legacy_aaz = None
74+
if legacy_aaz:
75+
load_aaz_command_table(
76+
loader=self,
77+
aaz_pkg_name=legacy_aaz.__name__,
78+
args=args
79+
)
80+
81+
from ._legacy.commands import load_command_table
82+
load_command_table(self, args)
83+
return self.command_table
84+
85+
def _load_new_command_table(self, args):
86+
"""Load commands from the current (refactored) implementation."""
87+
from azure.cli.command_modules.monitor.commands import load_command_table
88+
from azure.cli.core.aaz import load_aaz_command_table_args_guided
89+
4690
try:
4791
from . import aaz
4892
except ImportError:
4993
aaz = None
5094
if aaz:
51-
load_aaz_command_table(
95+
load_aaz_command_table_args_guided(
5296
loader=self,
5397
aaz_pkg_name=aaz.__name__,
5498
args=args
5599
)
100+
101+
try:
102+
from . import operations
103+
except ImportError:
104+
operations = None
105+
if operations:
106+
load_aaz_command_table_args_guided(
107+
loader=self,
108+
aaz_pkg_name=operations.__name__,
109+
args=args
110+
)
111+
56112
load_command_table(self, args)
57113
return self.command_table
58114

59115
def load_arguments(self, command):
60-
from azure.cli.command_modules.monitor._params import load_arguments
116+
if self._use_legacy:
117+
from azure.cli.command_modules.monitor._legacy._params import load_arguments
118+
else:
119+
from azure.cli.command_modules.monitor._params import load_arguments
61120
load_arguments(self, command)
62121

63122

0 commit comments

Comments
 (0)