From e69a8819c640ee151582cc8b8d0ae79a6dab8b97 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Fri, 7 Aug 2026 12:51:01 -0700 Subject: [PATCH] [Quantum] Add --provider-id to 'az quantum target list' for provider-account target listing --- src/quantum/HISTORY.rst | 4 ++ src/quantum/azext_quantum/_client_factory.py | 34 ++++++++- src/quantum/azext_quantum/_help.py | 7 +- src/quantum/azext_quantum/_params.py | 4 ++ src/quantum/azext_quantum/_validators.py | 21 ++++++ src/quantum/azext_quantum/commands.py | 4 +- .../azext_quantum/operations/target.py | 42 ++++++++++- .../tests/latest/test_quantum_targets.py | 72 +++++++++++++++++++ src/quantum/setup.py | 2 +- 9 files changed, 181 insertions(+), 9 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index f5e90b25774..76a4529bb5c 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +1.0.0b21 ++++++++++++++++ +* Added the ``--provider-id/-p`` parameter to ``az quantum target list`` to list targets and their status from a provider account, without requiring a workspace. + 1.0.0b20 +++++++++++++++ * Added the ``az quantum workspace user create`` and ``az quantum workspace user delete`` commands to manage user access to an Azure Quantum workspace. diff --git a/src/quantum/azext_quantum/_client_factory.py b/src/quantum/azext_quantum/_client_factory.py index 73808266889..997146b2959 100644 --- a/src/quantum/azext_quantum/_client_factory.py +++ b/src/quantum/azext_quantum/_client_factory.py @@ -6,11 +6,12 @@ # pylint: disable=line-too-long,protected-access import os +from azure.core.rest import HttpRequest from ._location_helper import normalize_location from .__init__ import CLI_REPORTED_VERSION from .vendored_sdks.azure_quantum_python._client import WorkspaceClient from .vendored_sdks.azure_mgmt_quantum import AzureQuantumMgmtClient -from .vendored_sdks.azure_mgmt_quantum.operations import WorkspacesOperations, OfferingsOperations +from .vendored_sdks.azure_mgmt_quantum.operations import WorkspacesOperations, OfferingsOperations, SuiteOffersOperations def is_env(name): @@ -28,6 +29,16 @@ def base_url(location): return f"https://{normalized_location}.quantum.azure.com/" +def base_url_v2(location): + # Provider accounts (suite offers) are a V2 concept whose data plane is served from a + # '-v2'-suffixed regional host (for example, 'westus-v2.quantum.azure.com'). An explicit + # AZURE_QUANTUM_BASEURL override is honored verbatim. + url = base_url(location) + if 'AZURE_QUANTUM_BASEURL' in os.environ: + return url + return url.replace('.quantum', '-v2.quantum', 1) + + def _get_data_credentials(cli_ctx, subscription_id=None): from azure.cli.core._profile import Profile profile = Profile(cli_ctx=cli_ctx) @@ -57,6 +68,10 @@ def cf_offerings(cli_ctx, *_) -> OfferingsOperations: return cf_quantum_mgmt(cli_ctx).offerings +def cf_suite_offers(cli_ctx, *_) -> SuiteOffersOperations: + return cf_quantum_mgmt(cli_ctx).suite_offers + + # Data Plane clients def cf_quantum(cli_ctx, subscription: str, resource_group: str, ws_name: str, endpoint: str | None) -> WorkspaceClient: @@ -81,6 +96,23 @@ def cf_quotas(cli_ctx, subscription: str, resource_group: str, ws_name: str, end return cf_quantum(cli_ctx, subscription, resource_group, ws_name, endpoint).services.quotas +def cf_provider_account_status(cli_ctx, subscription: str, location: str, provider_id: str): + """ + Fetch the provider-account (suite offer) status from the regional data plane, using the + generated WorkspaceClient's authenticated pipeline via a custom request. + """ + creds = _get_data_credentials(cli_ctx, subscription) + client = WorkspaceClient(base_url_v2(location), creds) + request = HttpRequest( + method="GET", + url=f"/subscriptions/{subscription}/providers/Microsoft.Quantum/suiteoffers/{provider_id}/providerStatus", + params={"api-version": "2026-01-15-preview"}, + ) + response = client.send_request(request) + response.raise_for_status() + return response.json() + + # Helper clients def cf_vm_image_term(cli_ctx): diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index c32a0a8ae7c..953ca23b838 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -217,11 +217,14 @@ helps['quantum target list'] = """ type: command - short-summary: Get the list of providers and their targets in an Azure Quantum workspace. + short-summary: Get the list of targets and their status in an Azure Quantum workspace or provider account. examples: - - name: Get the list of targets available in a Azure Quantum workspaces + - name: Get the list of targets available in an Azure Quantum workspace text: |- az quantum target list -g MyResourceGroup -w MyWorkspace + - name: Get the list of targets available in a provider account (no workspace required) + text: |- + az quantum target list -p MyProviderId """ helps['quantum target set'] = """ diff --git a/src/quantum/azext_quantum/_params.py b/src/quantum/azext_quantum/_params.py index ee196271938..467b047c840 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -96,6 +96,10 @@ def load_arguments(self, _): # pylint: disable=too-many-locals c.argument('workspace_name', workspace_name_type) c.argument('target_id', target_id_type) + with self.argument_context('quantum target list') as c: + c.argument('provider_id', provider_id_type) + c.argument('location', options_list=['--location', '-l'], help='Location (region) of the provider account. Used with --provider-id. If omitted, it is resolved automatically from the provider account.') + with self.argument_context('quantum target show') as c: c.argument('workspace_name', workspace_name_type) c.argument('target_id', target_id_type, required=False) diff --git a/src/quantum/azext_quantum/_validators.py b/src/quantum/azext_quantum/_validators.py index 34e642d2c84..fd78d54145c 100644 --- a/src/quantum/azext_quantum/_validators.py +++ b/src/quantum/azext_quantum/_validators.py @@ -45,6 +45,27 @@ def validate_workspace_and_target_info(cmd, namespace): validate_target_info(cmd, namespace) +def validate_target_list_info(cmd, namespace): + """ + Validate parameters for `az quantum target list`, which supports either a workspace context + (default) or a provider-account context via --provider-id. The two are mutually exclusive. + """ + from azure.cli.core.azclierror import MutuallyExclusiveArgumentError + + provider_id = getattr(namespace, 'provider_id', None) + if provider_id: + workspace_name = getattr(namespace, 'workspace_name', None) + # A configured default workspace should not trigger the mutual-exclusion error, so only + # treat an explicitly-provided workspace name (one that differs from the saved default) + # as a conflict. + default_workspace = cmd.cli_ctx.config.get(cmd.cli_ctx.config.defaults_section_name, 'workspace', None) + if workspace_name and workspace_name != default_workspace: + raise MutuallyExclusiveArgumentError( + "Specify either --provider-id/-p or --workspace-name/-w, not both.") + return + validate_workspace_info(cmd, namespace) + + def validate_provider_and_sku_info(cmd, namespace): """ Makes sure all parameters for quantum offering operations are present. diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index c95c447df7a..a30902fde6c 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -9,7 +9,7 @@ from collections import OrderedDict from azure.cli.core.commands import CliCommandType -from ._validators import validate_workspace_info, validate_target_info, validate_workspace_and_target_info, validate_provider_and_sku_info +from ._validators import validate_workspace_info, validate_target_info, validate_workspace_and_target_info, validate_provider_and_sku_info, validate_target_list_info logger = logging.getLogger(__name__) @@ -141,7 +141,7 @@ def load_command_table(self, _): u.command('delete', 'remove_user', validator=validate_workspace_info, confirmation=True) with self.command_group('quantum target', target_ops) as t: - t.command('list', 'list', validator=validate_workspace_info, table_transformer=transform_targets) + t.command('list', 'list', validator=validate_target_list_info, table_transformer=transform_targets) t.show_command('show', 'target_show', validator=validate_target_info) t.command('set', 'set', validator=validate_target_info) t.command('clear', 'clear') diff --git a/src/quantum/azext_quantum/operations/target.py b/src/quantum/azext_quantum/operations/target.py index a223399deed..c0b13db9ac3 100644 --- a/src/quantum/azext_quantum/operations/target.py +++ b/src/quantum/azext_quantum/operations/target.py @@ -5,7 +5,8 @@ # pylint: disable=line-too-long,redefined-builtin,unused-argument -from .._client_factory import cf_providers +from azure.cli.core.azclierror import InvalidArgumentValueError +from .._client_factory import cf_providers, cf_suite_offers, cf_provider_account_status from .._list_helper import repack_response_json from .workspace import WorkspaceInfo @@ -49,16 +50,51 @@ def set(cmd, target_id): return info -def list(cmd, resource_group_name, workspace_name): +def list(cmd, resource_group_name=None, workspace_name=None, provider_id=None, location=None): """ - Get the list of providers and their targets in an Azure Quantum workspace. + Get the list of providers and their targets in an Azure Quantum workspace, or in a + provider account when --provider-id is specified. """ + if provider_id: + return _list_by_provider_account(cmd, provider_id, location) + info = WorkspaceInfo(cmd, resource_group_name, workspace_name) client = cf_providers(cmd.cli_ctx, info.subscription, info.resource_group, info.name, info.endpoint) response = client.list(info.subscription, info.resource_group, info.name) return repack_response_json(response) +def _get_provider_account_location(cmd, provider_id): + """ + Resolve the region for a provider account (suite offer) from its subscription-level listing. + """ + for offer in cf_suite_offers(cmd.cli_ctx).list_by_subscription(): + if offer.properties.provider_id.lower() == provider_id.lower(): + return offer.properties.location + raise InvalidArgumentValueError(f"Provider account '{provider_id}' not found in the current subscription.") + + +def _list_by_provider_account(cmd, provider_id, location): + """ + Get the list of targets and their status for a provider account (suite offer). + """ + from azure.cli.core.commands.client_factory import get_subscription_id + + subscription = get_subscription_id(cmd.cli_ctx) + if not location: + location = _get_provider_account_location(cmd, provider_id) + + status = cf_provider_account_status(cmd.cli_ctx, subscription, location, provider_id) + + # Provider accounts that failed provisioning are omitted by the data-plane listing, so no + # additional filtering is required here. Normalize the response into a list of providers. + if isinstance(status, dict): + providers = status.get('value', [status]) + else: + providers = status + return providers + + def clear(cmd): """ Clear the default target-id. diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_targets.py b/src/quantum/azext_quantum/tests/latest/test_quantum_targets.py index 32bcead92f9..9c29aa6a13a 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_targets.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_targets.py @@ -75,3 +75,75 @@ def test_get_provider(self): assert test_returned_provider == test_expected_provider self.cmd(f'az quantum workspace delete -g {test_resource_group} -w {test_workspace_temp}') + + +class QuantumTargetListProviderAccountTest(unittest.TestCase): + """Unit tests (no Azure required) for the provider-account target listing support.""" + + def test_transform_targets_provider_account_shape(self): + from ...commands import transform_targets + + # Data-plane provider-account status uses the same shape as the workspace providers list. + providers = [ + { + 'id': 'atom-boulder', + 'currentAvailability': 'Available', + 'targets': [ + {'id': 'atom.qpu', 'currentAvailability': 'Available', 'averageQueueTime': 42} + ] + } + ] + rows = transform_targets(providers) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['Provider'], 'atom-boulder') + self.assertEqual(rows[0]['Target-id'], 'atom.qpu') + self.assertEqual(rows[0]['Current Availability'], 'Available') + self.assertEqual(rows[0]['Average Queue Time (seconds)'], 42) + + def test_target_list_provider_and_workspace_are_mutually_exclusive(self): + from azure.cli.core.azclierror import MutuallyExclusiveArgumentError + from ..._validators import validate_target_list_info + + cmd = self._fake_cmd(default_workspace=None) + namespace = self._fake_namespace(provider_id='atom-boulder', workspace_name='MyWorkspace') + with self.assertRaises(MutuallyExclusiveArgumentError): + validate_target_list_info(cmd, namespace) + + def test_target_list_provider_ignores_configured_default_workspace(self): + from ..._validators import validate_target_list_info + + # A saved default workspace must not conflict with an explicit --provider-id. + cmd = self._fake_cmd(default_workspace='MyDefaultWorkspace') + namespace = self._fake_namespace(provider_id='atom-boulder', workspace_name='MyDefaultWorkspace') + # Should not raise. + validate_target_list_info(cmd, namespace) + + @staticmethod + def _fake_cmd(default_workspace): + class _Config: + defaults_section_name = 'defaults' + + def get(self, _section, _key, default=None): + return default_workspace if default_workspace is not None else default + + class _Ctx: + config = _Config() + + class _Cmd: + cli_ctx = _Ctx() + + return _Cmd() + + @staticmethod + def _fake_namespace(**kwargs): + class _Namespace: + pass + + namespace = _Namespace() + for key, value in kwargs.items(): + setattr(namespace, key, value) + return namespace + + +if __name__ == '__main__': + unittest.main() diff --git a/src/quantum/setup.py b/src/quantum/setup.py index 22a1659670f..ea438d3bff6 100644 --- a/src/quantum/setup.py +++ b/src/quantum/setup.py @@ -17,7 +17,7 @@ # This version should match the latest entry in HISTORY.rst # Also, when updating this, please review the version used by the extension to # submit requests, which can be found at './azext_quantum/__init__.py' -VERSION = '1.0.0b20' +VERSION = '1.0.0b21' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers