Skip to content
Draft
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/quantum/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 33 additions & 1 deletion src/quantum/azext_quantum/_client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down
7 changes: 5 additions & 2 deletions src/quantum/azext_quantum/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'] = """
Expand Down
4 changes: 4 additions & 0 deletions src/quantum/azext_quantum/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions src/quantum/azext_quantum/_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/quantum/azext_quantum/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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')
Expand Down
42 changes: 39 additions & 3 deletions src/quantum/azext_quantum/operations/target.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
72 changes: 72 additions & 0 deletions src/quantum/azext_quantum/tests/latest/test_quantum_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion src/quantum/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading