From bf29151aef019cf2bfb289946c0f3b6e8c52af41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:33:02 +0000 Subject: [PATCH 1/2] Initial plan From fa59dd3232c80f2d02e4b27de0beeb077cee1fa7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 22 Jun 2026 22:39:19 +0000 Subject: [PATCH 2/2] [Load] Fix Azure/azure-cli-extensions#10020: az load test create: Fix --test-id not required when testId is in YAML config --- src/load/HISTORY.rst | 4 + .../azext_load/data_plane/load_test/help.py | 3 + .../azext_load/data_plane/utils/argtypes.py | 2 +- .../azext_load/data_plane/utils/constants.py | 1 + .../azext_load/data_plane/utils/validators.py | 14 +- .../test_load_test_id_from_yaml_unit.py | 229 ++++++++++++++++++ 6 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 src/load/azext_load/tests/latest/test_load_test_id_from_yaml_unit.py diff --git a/src/load/HISTORY.rst b/src/load/HISTORY.rst index 42ae84763bd..75ce0e14ab5 100644 --- a/src/load/HISTORY.rst +++ b/src/load/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +2.1.1 +++++++ +* Fix `az load test create`: `--test-id` is no longer required when `testId` is specified in the YAML configuration file passed via `--load-test-config-file`. + 2.1.0 ++++++ * Add option for `--autostop-engine-users` to set maximum users per engine for AutoStop criteria. diff --git a/src/load/azext_load/data_plane/load_test/help.py b/src/load/azext_load/data_plane/load_test/help.py index 2dbe8c3ffdb..d2a6678599c 100644 --- a/src/load/azext_load/data_plane/load_test/help.py +++ b/src/load/azext_load/data_plane/load_test/help.py @@ -14,6 +14,9 @@ type: command short-summary: Create a new load test. examples: + - name: Create a test with load test config file containing testId (--test-id can be omitted). + text: | + az load test create --load-test-resource sample-alt-resource --resource-group sample-rg --load-test-config-file ~/resources/sample-config.yaml - name: Create a test with load test config file. text: | az load test create --load-test-resource sample-alt-resource --resource-group sample-rg --test-id sample-test-id --load-test-config-file ~/resources/sample-config.yaml diff --git a/src/load/azext_load/data_plane/utils/argtypes.py b/src/load/azext_load/data_plane/utils/argtypes.py index 8ae1394997a..84894f4bb03 100644 --- a/src/load/azext_load/data_plane/utils/argtypes.py +++ b/src/load/azext_load/data_plane/utils/argtypes.py @@ -64,7 +64,7 @@ validator=validators.validate_test_id, options_list=["--test-id", "-t"], type=str, - help="Test ID of the load test", + help="Test ID of the load test. Required if not specified in the load test config file.", ) test_run_id = CLIArgumentType( diff --git a/src/load/azext_load/data_plane/utils/constants.py b/src/load/azext_load/data_plane/utils/constants.py index c545bc0d10e..ce18891763e 100644 --- a/src/load/azext_load/data_plane/utils/constants.py +++ b/src/load/azext_load/data_plane/utils/constants.py @@ -10,6 +10,7 @@ @dataclass class LoadTestConfigKeys: + TEST_ID = "testId" DISPLAY_NAME = "displayName" DESCRIPTION = "description" TEST_PLAN = "testPlan" diff --git a/src/load/azext_load/data_plane/utils/validators.py b/src/load/azext_load/data_plane/utils/validators.py index 95970a31c55..eed844513b7 100644 --- a/src/load/azext_load/data_plane/utils/validators.py +++ b/src/load/azext_load/data_plane/utils/validators.py @@ -43,7 +43,19 @@ def _validate_id(namespace, id_name, arg_name=None): def validate_test_id(namespace): - """Validates test-id""" + """Validates test-id. If test-id is not provided, tries to read it from the load test config file.""" + if getattr(namespace, "test_id", None) is None: + load_test_config_file = getattr(namespace, "load_test_config_file", None) + if load_test_config_file and os.path.isfile(load_test_config_file): + try: + with open(load_test_config_file, "r", encoding="UTF-8") as f: + data = yaml.safe_load(f) or {} + from .constants import LoadTestConfigKeys + test_id_from_yaml = data.get(LoadTestConfigKeys.TEST_ID) + if test_id_from_yaml is not None: + namespace.test_id = str(test_id_from_yaml) + except Exception: # pylint: disable=broad-except + pass _validate_id(namespace, "test_id", "test-id") diff --git a/src/load/azext_load/tests/latest/test_load_test_id_from_yaml_unit.py b/src/load/azext_load/tests/latest/test_load_test_id_from_yaml_unit.py new file mode 100644 index 00000000000..aa8fda354c7 --- /dev/null +++ b/src/load/azext_load/tests/latest/test_load_test_id_from_yaml_unit.py @@ -0,0 +1,229 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import tempfile +import unittest +import yaml + + +class SimpleNamespace: + """Minimal namespace mock for validator tests.""" + def __init__(self, **kwargs): + for k, v in kwargs.items(): + setattr(self, k, v) + + +def _get_validate_test_id(): + """Import validate_test_id directly from validators module.""" + import ast + import types + + validators_path = os.path.join( + os.path.dirname(__file__), + "..", "..", "data_plane", "utils", "validators.py" + ) + with open(validators_path, "r", encoding="UTF-8") as f: + source = f.read() + + # Extract only the functions we need without the full module-level imports + # by executing the full source in a sandboxed namespace with mocked imports + import sys + import re as re_mod + + mock_azure_error = type( + "InvalidArgumentValueError", (Exception,), {} + ) + mock_file_error = type("FileOperationError", (Exception,), {}) + + class MockLogger: + def debug(self, *a, **kw): pass + def info(self, *a, **kw): pass + def warning(self, *a, **kw): pass + def error(self, *a, **kw): pass + + # Build fake modules to satisfy imports + import types as types_mod + + def make_module(name, attrs=None): + mod = types_mod.ModuleType(name) + if attrs: + for k, v in attrs.items(): + setattr(mod, k, v) + return mod + + # Minimal stubs for modules used by validators + azure_cli_core_azclierror = make_module( + "azure.cli.core.azclierror", + {"InvalidArgumentValueError": mock_azure_error, + "FileOperationError": mock_file_error} + ) + azure_cli_core_params = make_module( + "azure.cli.core.commands.parameters", + {"get_subscription_locations": lambda cmd: []} + ) + azure_mgmt_core_tools = make_module( + "azure.mgmt.core.tools", + {"is_valid_resource_id": lambda rid: True} + ) + + # Import constants and models from real modules + import importlib.util + + def import_from_path(mod_name, rel_path): + full_path = os.path.normpath(os.path.join( + os.path.dirname(validators_path), rel_path + )) + spec = importlib.util.spec_from_file_location(mod_name, full_path) + mod = importlib.util.module_from_spec(spec) + return mod, spec + + # We need constants and models available under .constants and .models + constants_mod, constants_spec = import_from_path( + "azext_load.data_plane.utils.constants", + "constants.py" + ) + # constants.py imports models.py + models_path = os.path.normpath(os.path.join( + os.path.dirname(validators_path), "models.py" + )) + models_spec_obj = importlib.util.spec_from_file_location( + "azext_load.data_plane.utils.models", models_path + ) + models_mod = importlib.util.module_from_spec(models_spec_obj) + sys.modules["azext_load.data_plane.utils.models"] = models_mod + models_spec_obj.loader.exec_module(models_mod) + constants_spec.loader.exec_module(constants_mod) + sys.modules["azext_load.data_plane.utils.constants"] = constants_mod + + # Stub out vendored sdk module + vendored_stub = make_module( + "azext_load.vendored_sdks.loadtesting.models", + { + "NotificationEventType": type("NotificationEventType", (), {}), + "TestRunStatus": type("TestRunStatus", (), {}), + "PassFailTestResult": type("PassFailTestResult", (), {}), + } + ) + sys.modules.setdefault( + "azext_load.vendored_sdks.loadtesting.models", vendored_stub + ) + + # Stub utils (circular import avoidance) + utils_stub = make_module( + "azext_load.data_plane.utils.utils", + {} + ) + sys.modules.setdefault( + "azext_load.data_plane.utils.utils", utils_stub + ) + + # Stub azure hierarchy + for mod_name in [ + "azure", "azure.cli", "azure.cli.core", + "azure.cli.core.azclierror", "azure.cli.core.commands", + "azure.cli.core.commands.parameters", + "azure.mgmt", "azure.mgmt.core", "azure.mgmt.core.tools", + ]: + sys.modules.setdefault(mod_name, make_module(mod_name)) + + sys.modules["azure.cli.core.azclierror"] = azure_cli_core_azclierror + sys.modules["azure.cli.core.commands.parameters"] = azure_cli_core_params + sys.modules["azure.mgmt.core.tools"] = azure_mgmt_core_tools + + knack_log = make_module("knack.log", {"get_logger": lambda _: MockLogger()}) + sys.modules.setdefault("knack", make_module("knack")) + sys.modules["knack.log"] = knack_log + + # Stub utils module inline so '.' imports work + utils_inline = make_module( + "azext_load.data_plane.utils", + {"utils": utils_stub} + ) + sys.modules.setdefault("azext_load.data_plane.utils", utils_inline) + + # Now load validators + spec = importlib.util.spec_from_file_location( + "azext_load.data_plane.utils.validators", validators_path + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + return mod.validate_test_id, mock_azure_error + + +class TestValidateTestIdFromYaml(unittest.TestCase): + + def setUp(self): + self.validate_test_id, self.InvalidArgumentValueError = _get_validate_test_id() + + def _make_yaml_file(self, content): + """Write YAML content to a temp file and return its path.""" + tmp = tempfile.NamedTemporaryFile( + mode="w", suffix=".yaml", delete=False, encoding="utf-8" + ) + tmp.write(content) + tmp.flush() + tmp.close() + return tmp.name + + def test_test_id_required_when_not_in_cli_or_yaml(self): + """Error raised when test_id is absent from both CLI arg and YAML.""" + ns = SimpleNamespace(test_id=None) + with self.assertRaises(self.InvalidArgumentValueError): + self.validate_test_id(ns) + + def test_test_id_required_when_yaml_missing_key(self): + """Error raised when YAML config file exists but has no testId key.""" + yaml_file = self._make_yaml_file("displayName: mytest\n") + try: + ns = SimpleNamespace(test_id=None, load_test_config_file=yaml_file) + with self.assertRaises(self.InvalidArgumentValueError): + self.validate_test_id(ns) + finally: + os.unlink(yaml_file) + + def test_test_id_read_from_yaml(self): + """test_id is set from testId in YAML when CLI arg is absent.""" + yaml_content = "testId: my-yaml-test-id\ndisplayName: mytest\n" + yaml_file = self._make_yaml_file(yaml_content) + try: + ns = SimpleNamespace(test_id=None, load_test_config_file=yaml_file) + self.validate_test_id(ns) + self.assertEqual(ns.test_id, "my-yaml-test-id") + finally: + os.unlink(yaml_file) + + def test_cli_test_id_takes_precedence_over_yaml(self): + """CLI-provided test_id is used even when YAML also has testId.""" + yaml_content = "testId: yaml-test-id\ndisplayName: mytest\n" + yaml_file = self._make_yaml_file(yaml_content) + try: + ns = SimpleNamespace(test_id="cli-test-id", load_test_config_file=yaml_file) + self.validate_test_id(ns) + self.assertEqual(ns.test_id, "cli-test-id") + finally: + os.unlink(yaml_file) + + def test_test_id_validates_format(self): + """Invalid test_id value (uppercase) raises InvalidArgumentValueError.""" + yaml_content = "testId: InvalidTestID\n" + yaml_file = self._make_yaml_file(yaml_content) + try: + ns = SimpleNamespace(test_id=None, load_test_config_file=yaml_file) + with self.assertRaises(self.InvalidArgumentValueError): + self.validate_test_id(ns) + finally: + os.unlink(yaml_file) + + def test_valid_cli_test_id_passes(self): + """A valid CLI test_id passes validation without error.""" + ns = SimpleNamespace(test_id="valid-test-id-123") + self.validate_test_id(ns) # Should not raise + self.assertEqual(ns.test_id, "valid-test-id-123") + + +if __name__ == "__main__": + unittest.main()