Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ exclude =
vendored_sdks
tests
*/command_modules/*/aaz
*/_legacy
2 changes: 1 addition & 1 deletion pylintrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[MASTER]
ignore=tests,generated,vendored_sdks,privates
ignore=tests,generated,vendored_sdks,privates,_legacy
ignore-patterns=test.*,azure_devops_build.*
ignore-paths=.*/command_modules/.*/aaz
reports=no
Expand Down
3 changes: 2 additions & 1 deletion src/azure-cli-core/azure/cli/core/aaz/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
AAZPaginationTokenArgFormat
from ._base import has_value, AAZValuePatch, AAZUndefined
from ._command import AAZCommand, AAZWaitCommand, AAZCommandGroup, \
register_callback, register_command, register_command_group, load_aaz_command_table, link_helper
register_callback, register_command, register_command_group, load_aaz_command_table, \
load_aaz_command_table_args_guided, link_helper
from ._field_type import AAZIntType, AAZFloatType, AAZStrType, AAZBoolType, AAZDictType, AAZFreeFormDictType, \
AAZListType, AAZObjectType, AAZIdentityObjectType, AAZAnyType
from ._operation import AAZHttpOperation, AAZJsonInstanceUpdateOperation, AAZGenericInstanceUpdateOperation, \
Expand Down
175 changes: 175 additions & 0 deletions src/azure-cli-core/azure/cli/core/aaz/_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,181 @@ def decorator(cls):
AAZ_PACKAGE_FULL_LOAD_ENV_NAME = 'AZURE_AAZ_FULL_LOAD'


def load_aaz_command_table_args_guided(loader, aaz_pkg_name, args):
"""Args-guided AAZ command tree loader.

Instead of importing the entire AAZ package tree (all __init__.py files which eagerly
import all command classes), this function navigates only to the relevant subtree based
on CLI args. For example, ``az monitor log-analytics workspace create --help`` only loads
the ``workspace`` sub-package and the ``_create`` module, skipping all other commands.

This requires that AAZ ``__init__.py`` files do NOT contain wildcard imports
(``from ._create import *`` etc.) -- they should be empty (just the license header).
"""
profile_pkg = _get_profile_pkg(aaz_pkg_name, loader.cli_ctx.cloud)

command_table = {}
command_group_table = {}
if args is None or os.environ.get(AAZ_PACKAGE_FULL_LOAD_ENV_NAME, 'False').lower() == 'true':
effective_args = None # fully load
else:
effective_args = list(args)
if profile_pkg is not None:
_load_aaz_by_pkg(loader, profile_pkg, effective_args,
command_table, command_group_table)

for group_name, command_group in command_group_table.items():
loader.command_group_table[group_name] = command_group
for command_name, command in command_table.items():
loader.command_table[command_name] = command
return command_table, command_group_table


def _try_import_module(relative_name, package):
"""Try to import a module by relative name, return None on failure."""
try:
return importlib.import_module(relative_name, package)
except ModuleNotFoundError as ex:
# Only treat "module not found" for the requested module as a benign miss.
target_mod_name = f"{package}.{relative_name.lstrip('.')}"
if ex.name == target_mod_name:
return None
# Different module is missing; propagate so the real error surfaces.
raise
except ImportError:
logger.error("Error importing module %r from package %r", relative_name, package)
raise


def _register_from_module(loader, mod, command_table, command_group_table):
"""Scan a module's namespace for AAZCommand/AAZCommandGroup classes and register them."""
for value in mod.__dict__.values():
if not isinstance(value, type):
continue
if value.__module__ != mod.__name__: # skip imported classes
continue
if issubclass(value, AAZCommandGroup) and value.AZ_NAME:
command_group_table[value.AZ_NAME] = value(cli_ctx=loader.cli_ctx)
elif issubclass(value, AAZCommand) and value.AZ_NAME:
command_table[value.AZ_NAME] = value(loader=loader)


def _get_pkg_children(pkg):
"""List child entries of a package using pkgutil.

Returns two sets: (file_stems, subdir_names).
- file_stems: module-like stems, e.g. {'_create', '_list', '__cmd_group'}
- subdir_names: sub-package directory names, e.g. {'namespace', 'eventhub'}
"""
import pkgutil
file_stems = set()
subdir_names = set()

pkg_path = getattr(pkg, '__path__', None)
if not pkg_path:
return file_stems, subdir_names

for _importer, name, ispkg in pkgutil.iter_modules(pkg_path):
if ispkg:
if not name.startswith('_'):
subdir_names.add(name)
else:
file_stems.add(name)

return file_stems, subdir_names


def _load_aaz_by_pkg(loader, pkg, args, command_table, command_group_table):
"""Recursively navigate the AAZ package tree guided by CLI args.

- args is None -> full recursive load of all commands under this package.
- args is empty list -> args exhausted; load current level's commands and sub-group headers.
- args has items -> try to match first arg as a command module or sub-package,
recurse with remaining args on match.
- no match on first arg -> load current level's commands and sub-group headers.
"""
base_module = pkg.__name__
file_stems, subdir_names = _get_pkg_children(pkg)

if args is not None and args and not args[0].startswith('-'):
first_arg = args[0].lower().replace('-', '_')

# First arg matches a command module (e.g. "create" -> "_create")
if f"_{first_arg}" in file_stems:
mod = _try_import_module(f"._{first_arg}", base_module)
if mod:
_register_from_module(loader, mod, command_table, command_group_table)
return

# First arg matches a sub-package (command group)
if first_arg in subdir_names:
sub_module = f"{base_module}.{first_arg}"
mod = _try_import_module('.__cmd_group', sub_module)
if mod:
_register_from_module(loader, mod, command_table, command_group_table)
sub_pkg = _try_import_module(f'.{first_arg}', base_module)
if sub_pkg:
_load_aaz_by_pkg(loader, sub_pkg, args[1:], command_table, command_group_table)
return

# Load __cmd_group + all command modules at this level
mod = _try_import_module('.__cmd_group', base_module)
if mod:
_register_from_module(loader, mod, command_table, command_group_table)

for stem in file_stems:
if stem.startswith('_') and not stem.startswith('__'):
mod = _try_import_module(f'.{stem}', base_module)
if mod:
_register_from_module(loader, mod, command_table, command_group_table)

for subdir in subdir_names:
sub_module = f"{base_module}.{subdir}"
if args is None:
# Full load -> recurse into every sub-package
sub_pkg = _try_import_module(f'.{subdir}', base_module)
if sub_pkg:
_load_aaz_by_pkg(loader, sub_pkg, None, command_table, command_group_table)
else:
# Args exhausted / not matched -> load sub-group header and the first
# command so the group is non-empty and the parser creates a subparser
# for it (required for help output).
# TODO: After optimized loading is applied to the whole CLI, revisit
# this and consider a lighter approach (e.g. parser-level fix) to
# avoid importing one command per trimmed sub-group.
mod = _try_import_module('.__cmd_group', sub_module)
if mod:
_register_from_module(loader, mod, command_table, command_group_table)
sub_pkg = _try_import_module(f'.{subdir}', base_module)
if sub_pkg:
_load_first_command(loader, sub_pkg, command_table)


def _load_first_command(loader, pkg, command_table):
"""Load the first available command module from a package.

This ensures the command group is non-empty so the parser creates a subparser
for it, which is required for it to appear in help output.
"""
file_stems, subdir_names = _get_pkg_children(pkg)
base_module = pkg.__name__

# Try to load a command module at this level first
for stem in sorted(file_stems):
if stem.startswith('_') and not stem.startswith('__'):
mod = _try_import_module(f'.{stem}', base_module)
if mod:
_register_from_module(loader, mod, command_table, {})
return

# No command at this level, recurse into the first sub-package
for subdir in sorted(subdir_names):
sub_pkg = _try_import_module(f'.{subdir}', base_module)
if sub_pkg:
_load_first_command(loader, sub_pkg, command_table)
return


def load_aaz_command_table(loader, aaz_pkg_name, args):
""" This function is used in AzCommandsLoader.load_command_table.
It will load commands in module's aaz package.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Monitor Module: Legacy Fallback

## Overview

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:

```bash
az config set monitor.use_legacy=true # enable legacy mode
az config set monitor.use_legacy=false # switch back to new mode (default)
```

A warning is logged each time legacy mode is active.

## How It Works

In `__init__.py`, `MonitorCommandsLoader` reads the `monitor.use_legacy` config (default `false`):

- **New mode** — loads from `aaz/`, `operations/`, and `commands.py` using `load_aaz_command_table_args_guided`.
- **Legacy mode** — loads from `_legacy/aaz/`, `_legacy/commands.py` using `load_aaz_command_table`. Arguments come from `_legacy/_params.py`.

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.`.

## Known Adjustments

- **`_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.
- **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.
- **Linting**: `_legacy/` is excluded via `pylintrc` (`ignore` list) and `.flake8` (`exclude` list).

## Dropping Legacy Support

When legacy mode is no longer needed:

1. **Delete the `_legacy/` folder**:
```bash
rm -rf src/azure-cli/azure/cli/command_modules/monitor/_legacy/
```

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`:
```python
# Remove these
_CONFIG_SECTION = 'monitor'
_USE_LEGACY_CONFIG_KEY = 'use_legacy'
self._use_legacy = ...
def _load_legacy_command_table(self, args): ...

# Keep only _load_new_command_table logic directly in load_command_table
```

3. **Clean up tests** — remove the second `mock.patch` line for `_legacy` in `test_monitor_general_operations.py`:
```python
# Remove this line from each mock.patch block:
mock.patch('azure.cli.command_modules.monitor._legacy.operations.monitor_clone_util.gen_guid', ...)
```

4. **Revert linter config** — remove `_legacy` from `pylintrc` `ignore` and `*/_legacy` from `.flake8` `exclude`.
71 changes: 65 additions & 6 deletions src/azure-cli/azure/cli/command_modules/monitor/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

from knack.log import get_logger

from azure.cli.core import AzCommandsLoader
from azure.cli.core.commands import AzArgumentContext, CliCommandType
from azure.cli.command_modules.monitor._help import helps # pylint: disable=unused-import

logger = get_logger(__name__)

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


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

def __init__(self, cli_ctx=None):
from azure.cli.core.profiles import ResourceType
monitor_custom = CliCommandType(
operations_tmpl='azure.cli.command_modules.monitor.custom#{}')
self._use_legacy = cli_ctx.config.getboolean(
_CONFIG_SECTION, _USE_LEGACY_CONFIG_KEY, fallback=False) if cli_ctx else False
if self._use_legacy:
monitor_custom = CliCommandType(
operations_tmpl='azure.cli.command_modules.monitor._legacy.custom#{}')
else:
monitor_custom = CliCommandType(
operations_tmpl='azure.cli.command_modules.monitor.custom#{}')
super().__init__(cli_ctx=cli_ctx,
resource_type=ResourceType.MGMT_MONITOR,
argument_context_cls=MonitorArgumentContext,
custom_command_type=monitor_custom)

def load_command_table(self, args):
from azure.cli.command_modules.monitor.commands import load_command_table
if self._use_legacy:
return self._load_legacy_command_table(args)
return self._load_new_command_table(args)

def _load_legacy_command_table(self, args):
"""Load commands from the vendored _legacy snapshot (pre-refactor code from dev branch)."""
from azure.cli.core.aaz import load_aaz_command_table

logger.warning(
"The monitor module is using legacy mode. "
"To switch to the new optimized implementation, run: "
"az config set %s.%s=false",
_CONFIG_SECTION, _USE_LEGACY_CONFIG_KEY)

try:
from ._legacy import aaz as legacy_aaz
except ImportError:
legacy_aaz = None
if legacy_aaz:
load_aaz_command_table(
loader=self,
aaz_pkg_name=legacy_aaz.__name__,
args=args
)

from ._legacy.commands import load_command_table
load_command_table(self, args)
return self.command_table

def _load_new_command_table(self, args):
"""Load commands from the current (refactored) implementation."""
from azure.cli.command_modules.monitor.commands import load_command_table
from azure.cli.core.aaz import load_aaz_command_table_args_guided

try:
from . import aaz
except ImportError:
aaz = None
if aaz:
load_aaz_command_table(
load_aaz_command_table_args_guided(
loader=self,
aaz_pkg_name=aaz.__name__,
args=args
)

try:
from . import operations
except ImportError:
operations = None
if operations:
load_aaz_command_table_args_guided(
loader=self,
aaz_pkg_name=operations.__name__,
args=args
)

load_command_table(self, args)
return self.command_table

def load_arguments(self, command):
from azure.cli.command_modules.monitor._params import load_arguments
if self._use_legacy:
from azure.cli.command_modules.monitor._legacy._params import load_arguments
else:
from azure.cli.command_modules.monitor._params import load_arguments
load_arguments(self, command)


Expand Down
Loading
Loading