From fd10c2bca7a7d4cfd62d64ac19968897ac35e680 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Fri, 28 Aug 2026 14:26:05 -0700 Subject: [PATCH 01/24] [Quantum] Add 'az quantum suite-offer list' command List the Quantum suite offers available to the subscription (provider, location, and subscription-level quota allocations) via the control-plane SuiteOffers API. Bumps the extension to 1.0.0b24. --- src/quantum/HISTORY.rst | 4 ++ src/quantum/azext_quantum/_client_factory.py | 6 ++- src/quantum/azext_quantum/_help.py | 17 ++++++++ src/quantum/azext_quantum/commands.py | 17 ++++++++ .../azext_quantum/operations/suite_offers.py | 16 +++++++ .../tests/latest/test_quantum_suite_offers.py | 42 +++++++++++++++++++ src/quantum/setup.py | 2 +- 7 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/quantum/azext_quantum/operations/suite_offers.py create mode 100644 src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index 5ef4464cd0a..04d6328c36c 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +1.0.0b24 +++++++++++++++ +* Added the ``az quantum suite-offer list`` command to list the suite offers available to the subscription, including provider, location, and subscription-level quota allocations. + 1.0.0b23 ++++++++++++++ * Added ``--quota`` support to ``az quantum workspace create`` and ``az quantum workspace update`` for managing V2 provider target quota allocations. diff --git a/src/quantum/azext_quantum/_client_factory.py b/src/quantum/azext_quantum/_client_factory.py index 73808266889..dec0d1502c0 100644 --- a/src/quantum/azext_quantum/_client_factory.py +++ b/src/quantum/azext_quantum/_client_factory.py @@ -10,7 +10,7 @@ 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): @@ -57,6 +57,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: diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 90d26e8a509..93e0a370fad 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -216,6 +216,23 @@ -j yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy --job-tags tag1 tag2 """ +helps['quantum suite-offer'] = """ + type: group + short-summary: Manage Azure Quantum suite offers available to the subscription. +""" + +helps['quantum suite-offer list'] = """ + type: command + short-summary: List the Azure Quantum suite offers available to the current subscription, including provider, location, and subscription-level quota allocations. + examples: + - name: List all suite offers available to the current subscription. + text: |- + az quantum suite-offer list -o table + - name: List the provider ID and location of each available suite offer. + text: |- + az quantum suite-offer list --query "[].{provider:properties.providerId, location:properties.location}" -o table +""" + helps['quantum offerings'] = """ type: group short-summary: Manage provider offerings for Azure Quantum. diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 982905be2a9..8093b2a7631 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -79,6 +79,19 @@ def one(offering): return [one(offering) for offering in offerings] +def transform_suite_offers(suite_offers): + def one(offer): + properties = offer['properties'] + return OrderedDict([ + ('Provider Id', properties['providerId']), + ('Provider Name', properties['providerName']), + ('Company', properties['companyName']), + ('Location', properties['location']) + ]) + + return [one(offer) for offer in suite_offers] + + def transform_output(results): def one(key, value): repeat = round(20 * value) @@ -134,6 +147,7 @@ def load_command_table(self, _): job_ops = CliCommandType(operations_tmpl='azext_quantum.operations.job#{}') target_ops = CliCommandType(operations_tmpl='azext_quantum.operations.target#{}') offerings_ops = CliCommandType(operations_tmpl='azext_quantum.operations.offerings#{}') + suite_offers_ops = CliCommandType(operations_tmpl='azext_quantum.operations.suite_offers#{}') with self.command_group('quantum workspace', workspace_ops) as w: w.command('create', 'create') @@ -177,3 +191,6 @@ def load_command_table(self, _): o.command('list', 'list_offerings', table_transformer=transform_offerings) o.command('accept-terms', 'accept_terms', validator=validate_provider_and_sku_info) o.command('show-terms', 'show_terms', validator=validate_provider_and_sku_info) + + with self.command_group('quantum suite-offer', suite_offers_ops) as s: + s.command('list', 'list_suite_offers', table_transformer=transform_suite_offers) diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py new file mode 100644 index 00000000000..ee33bf4a8af --- /dev/null +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -0,0 +1,16 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=line-too-long + +from .._client_factory import cf_suite_offers + + +def list_suite_offers(cmd): + """ + List the Azure Quantum suite offers available to the current subscription. + """ + client = cf_suite_offers(cmd.cli_ctx) + return client.list_by_subscription() diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py new file mode 100644 index 00000000000..f04a0b9e587 --- /dev/null +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -0,0 +1,42 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azure.cli.testsdk.scenario_tests import live_only +from azure.cli.testsdk import ScenarioTest + +from ...commands import transform_suite_offers + + +class QuantumSuiteOffersScenarioTest(ScenarioTest): + + def test_transform_suite_offers(self): + suite_offers = [ + { + 'id': '/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Quantum/suiteOffers/ionq', + 'name': 'ionq', + 'properties': { + 'providerId': 'ionq', + 'providerName': 'IonQ', + 'companyName': 'IonQ, Inc.', + 'location': 'eastus', + 'description': 'IonQ quantum computing offer.' + } + } + ] + + table = transform_suite_offers(suite_offers) + + self.assertEqual(len(table), 1) + row = table[0] + self.assertEqual(list(row.keys()), ['Provider Id', 'Provider Name', 'Company', 'Location']) + self.assertEqual(row['Provider Id'], 'ionq') + self.assertEqual(row['Provider Name'], 'IonQ') + self.assertEqual(row['Company'], 'IonQ, Inc.') + self.assertEqual(row['Location'], 'eastus') + + @live_only() + def test_quantum_suite_offer_list(self): + offers = self.cmd('az quantum suite-offer list').get_output_in_json() + assert isinstance(offers, list) diff --git a/src/quantum/setup.py b/src/quantum/setup.py index 946fb49699c..454c572b6b8 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.0b23' +VERSION = '1.0.0b24' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From 25bb4d904bb0430e88f268008b1b989e61fa30ae Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Fri, 28 Aug 2026 14:56:20 -0700 Subject: [PATCH 02/24] [Quantum] Bump extension version to 1.0.0b25 --- src/quantum/HISTORY.rst | 2 +- src/quantum/setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index 04d6328c36c..9997bc38228 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -3,7 +3,7 @@ Release History =============== -1.0.0b24 +1.0.0b25 ++++++++++++++ * Added the ``az quantum suite-offer list`` command to list the suite offers available to the subscription, including provider, location, and subscription-level quota allocations. diff --git a/src/quantum/setup.py b/src/quantum/setup.py index 454c572b6b8..d241880cff5 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.0b24' +VERSION = '1.0.0b25' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From a3640fb9ad2268c98d8481e35dbb09acc9ba2382 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Mon, 31 Aug 2026 10:53:09 -0700 Subject: [PATCH 03/24] [Quantum] Address review: use 'View' in suite-offer group help --- src/quantum/azext_quantum/_help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 93e0a370fad..e4d443f69fb 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -218,7 +218,7 @@ helps['quantum suite-offer'] = """ type: group - short-summary: Manage Azure Quantum suite offers available to the subscription. + short-summary: View Azure Quantum suite offers available to the subscription. """ helps['quantum suite-offer list'] = """ From d9da06eb698bce7fd80e88526eef0ad8f759538b Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Mon, 31 Aug 2026 14:29:56 -0700 Subject: [PATCH 04/24] [Quantum] Address review: use 'Provider ID' column for consistency --- src/quantum/azext_quantum/commands.py | 2 +- .../azext_quantum/tests/latest/test_quantum_suite_offers.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 0faea7eba39..d74eb0a00a8 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -94,7 +94,7 @@ def transform_suite_offers(suite_offers): def one(offer): properties = offer['properties'] return OrderedDict([ - ('Provider Id', properties['providerId']), + ('Provider ID', properties['providerId']), ('Provider Name', properties['providerName']), ('Company', properties['companyName']), ('Location', properties['location']) diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index f04a0b9e587..b7669d5c6e3 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -30,8 +30,8 @@ def test_transform_suite_offers(self): self.assertEqual(len(table), 1) row = table[0] - self.assertEqual(list(row.keys()), ['Provider Id', 'Provider Name', 'Company', 'Location']) - self.assertEqual(row['Provider Id'], 'ionq') + self.assertEqual(list(row.keys()), ['Provider ID', 'Provider Name', 'Company', 'Location']) + self.assertEqual(row['Provider ID'], 'ionq') self.assertEqual(row['Provider Name'], 'IonQ') self.assertEqual(row['Company'], 'IonQ, Inc.') self.assertEqual(row['Location'], 'eastus') From 0d79e291e34f08604c78b89b39c6fc2630d42b80 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Tue, 1 Sep 2026 10:20:21 -0700 Subject: [PATCH 05/24] [Quantum] Add 'az quantum suite-offer quotas' command Adds 'az quantum suite-offer quotas --provider-id' which returns v2 quota allocations merged with their consumed usages for a suite offer provider account. Combines the control-plane suite offer allocations with the data-plane (-v2 endpoint) quota usages, reporting allocated/used/remaining standard and high priority minutes per subscription and target scope. --- src/quantum/HISTORY.rst | 4 + src/quantum/azext_quantum/_client_factory.py | 13 ++ src/quantum/azext_quantum/_help.py | 16 ++ src/quantum/azext_quantum/_params.py | 3 + src/quantum/azext_quantum/commands.py | 24 +++ .../azext_quantum/operations/suite_offers.py | 110 ++++++++++++- .../tests/latest/test_quantum_suite_offers.py | 148 +++++++++++++++++- .../_client/aio/operations/_operations.py | 82 ++++++++++ .../_client/models/__init__.py | 4 + .../_client/models/_models.py | 56 +++++++ .../_client/operations/_operations.py | 108 +++++++++++++ src/quantum/setup.py | 2 +- 12 files changed, 567 insertions(+), 3 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index 7e109e3d663..88b287f48c5 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +1.0.0b26 +++++++++++++++ +* Added the ``az quantum suite-offer quotas`` command to view quota allocations merged with their consumed usages for a suite offer provider account in the subscription. + 1.0.0b25 ++++++++++++++ * Added the ``az quantum suite-offer list`` command to list the suite offers available to the subscription, including provider, location, and subscription-level quota allocations. diff --git a/src/quantum/azext_quantum/_client_factory.py b/src/quantum/azext_quantum/_client_factory.py index dec0d1502c0..52752330fec 100644 --- a/src/quantum/azext_quantum/_client_factory.py +++ b/src/quantum/azext_quantum/_client_factory.py @@ -28,6 +28,15 @@ def base_url(location): return f"https://{normalized_location}.quantum.azure.com/" +def base_url_v2(location): + if 'AZURE_QUANTUM_BASEURL_V2' in os.environ: + return os.environ['AZURE_QUANTUM_BASEURL_V2'] + normalized_location = normalize_location(location) + if is_env('dogfood'): + return f"https://{normalized_location}-v2.quantum-test.azure.com/" + return f"https://{normalized_location}-v2.quantum.azure.com/" + + def _get_data_credentials(cli_ctx, subscription_id=None): from azure.cli.core._profile import Profile profile = Profile(cli_ctx=cli_ctx) @@ -85,6 +94,10 @@ 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_suite_offer_quota_usages(cli_ctx, subscription: str, endpoint: str): + return cf_quantum(cli_ctx, subscription, None, None, endpoint).services.suite_offers + + # 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 c67b5f7190c..a9c8f12a181 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -233,6 +233,22 @@ az quantum suite-offer list --query "[].{provider:properties.providerId, location:properties.location}" -o table """ +helps['quantum suite-offer quotas'] = """ + type: command + short-summary: View quota allocations and their consumed usages for a suite offer provider account in the current subscription. + long-summary: | + Returns the v2 quota allocations (limits) for the provider account merged with the consumed + usages, for both subscription-level and target-level scopes. Each entry reports the allocated, + used, and remaining standard and high priority minutes over the lifetime of the provider account. + examples: + - name: View the quota usages for a suite offer provider account. + text: |- + az quantum suite-offer quotas --provider-id MyProviderAccount -o table + - name: View the raw quota usage details for a suite offer provider account. + text: |- + az quantum suite-offer quotas -p MyProviderAccount +""" + helps['quantum offerings'] = """ type: group short-summary: Manage provider offerings for Azure Quantum. diff --git a/src/quantum/azext_quantum/_params.py b/src/quantum/azext_quantum/_params.py index 3e2a90f5b5f..877db2e75a4 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -316,3 +316,6 @@ def load_arguments(self, _): # pylint: disable=too-many-locals c.argument('workspace_name', workspace_name_type) c.argument('enable_key', enable_key_type) c.argument('quota', quota_type) + + with self.argument_context('quantum suite-offer quotas') as c: + c.argument('provider_id', provider_id_type, required=True) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index d74eb0a00a8..2c028a217ca 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -103,6 +103,29 @@ def one(offer): return [one(offer) for offer in suite_offers] +def transform_suite_offer_quotas(quotas): + def one(quota): + standard = quota.get('standardMinutesLifetime') or {} + high = quota.get('highMinutesLifetime') or {} + + def cell(source, key): + value = source.get(key) + return '' if value is None else value + + return OrderedDict([ + ('Scope', quota.get('scope')), + ('Target', quota.get('targetId', '')), + ('Std Allocated', cell(standard, 'allocated')), + ('Std Used', cell(standard, 'used')), + ('Std Remaining', cell(standard, 'remaining')), + ('High Allocated', cell(high, 'allocated')), + ('High Used', cell(high, 'used')), + ('High Remaining', cell(high, 'remaining')) + ]) + + return [one(quota) for quota in quotas] + + def transform_output(results): def one(key, value): repeat = round(20 * value) @@ -206,3 +229,4 @@ def load_command_table(self, _): with self.command_group('quantum suite-offer', suite_offers_ops) as s: s.command('list', 'list_suite_offers', table_transformer=transform_suite_offers) + s.command('quotas', 'suite_offer_quotas', table_transformer=transform_suite_offer_quotas) diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py index ee33bf4a8af..93c1ae31382 100644 --- a/src/quantum/azext_quantum/operations/suite_offers.py +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -5,7 +5,13 @@ # pylint: disable=line-too-long -from .._client_factory import cf_suite_offers +from collections import OrderedDict + +from azure.cli.core.azclierror import InvalidArgumentValueError, ResourceNotFoundError +from azure.cli.core.commands.client_factory import get_subscription_id +from azure.core.exceptions import ResourceNotFoundError as AzureResourceNotFoundError + +from .._client_factory import cf_suite_offers, cf_suite_offer_quota_usages, base_url_v2 def list_suite_offers(cmd): @@ -14,3 +20,105 @@ def list_suite_offers(cmd): """ client = cf_suite_offers(cmd.cli_ctx) return client.list_by_subscription() + + +def suite_offer_quotas(cmd, provider_id): + """ + Return the v2 quota allocations, merged with their consumed usages, for a suite offer + provider account in the current subscription. + """ + subscription_id = get_subscription_id(cmd.cli_ctx) + + # 1. Control-plane: locate the suite offer for the requested provider account. + offers = cf_suite_offers(cmd.cli_ctx).list_by_subscription() + offer = next( + (o for o in offers + if o.properties is not None + and o.properties.provider_id is not None + and o.properties.provider_id.lower() == provider_id.lower()), + None, + ) + if offer is None: + raise InvalidArgumentValueError( + f"No suite offer was found for provider account '{provider_id}' in subscription '{subscription_id}'." + ) + + # 2. Data-plane (v2): fetch the consumed quota usages for that provider account. + endpoint = base_url_v2(offer.properties.location) + client = cf_suite_offer_quota_usages(cmd.cli_ctx, subscription_id, endpoint) + try: + usages = client.list_quota_usages(subscription_id, provider_id) + except AzureResourceNotFoundError as ex: + raise ResourceNotFoundError( + f"No quota usages were found for provider account '{provider_id}'." + ) from ex + + # 3. Merge allocations (limits) with usages (consumed). + return _merge_suite_offer_quotas(offer, usages, provider_id) + + +def _minutes_row(allocated, used): + """Build the per-priority {allocated, used, remaining} entry, or None if nothing is known.""" + if allocated is None and used is None: + return None + remaining = None + if allocated is not None and used is not None: + remaining = allocated - used + return OrderedDict([("allocated", allocated), ("used", used), ("remaining", remaining)]) + + +def _merge_suite_offer_quotas(offer, usages, provider_id): + """ + Combine the ARM quota allocations (limits) with the data-plane quota usages (consumed), + keyed by target id (None represents the subscription-level allocation). + """ + props = offer.properties + + # Allocations keyed by target id (None => subscription-level). + allocations = {} + if props.quotas is not None: + allocations[None] = props.quotas + for target_quota in props.target_quotas or []: + allocations[target_quota.target_id] = target_quota + + # Usages keyed by target id (None => subscription-level). + usage_by_key = {} + for usage in usages or []: + usage_by_key[usage.target_id] = usage + + rows = [] + # Emit the subscription-level row first, then target rows sorted by target id. + keys = list(allocations.keys()) + [k for k in usage_by_key if k not in allocations] + ordered_keys = [None] if (None in allocations or None in usage_by_key) else [] + ordered_keys += sorted(k for k in keys if k is not None) + + for key in ordered_keys: + allocation = allocations.get(key) + usage = usage_by_key.get(key) + + std_allocated = allocation.standard_minutes_lifetime if allocation is not None else None + high_allocated = allocation.high_minutes_lifetime if allocation is not None else None + + usage_values = usage.usage if usage is not None else None + std_used = usage_values.standard_minutes_lifetime if usage_values is not None else None + high_used = usage_values.high_minutes_lifetime if usage_values is not None else None + + row = OrderedDict() + row["providerId"] = provider_id + row["scope"] = "Subscription" if key is None else "SubscriptionTarget" + if key is not None: + row["targetId"] = key + + std = _minutes_row(std_allocated, std_used) + if std is not None: + row["standardMinutesLifetime"] = std + high = _minutes_row(high_allocated, high_used) + if high is not None: + row["highMinutesLifetime"] = high + + if usage is not None and usage.last_modified_time is not None: + row["lastModifiedTime"] = usage.last_modified_time + + rows.append(row) + + return rows diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index b7669d5c6e3..f23aa016619 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -3,10 +3,44 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from types import SimpleNamespace + from azure.cli.testsdk.scenario_tests import live_only from azure.cli.testsdk import ScenarioTest -from ...commands import transform_suite_offers +from ...commands import transform_suite_offers, transform_suite_offer_quotas +from ..._client_factory import base_url_v2 +from ...operations.suite_offers import _merge_suite_offer_quotas +from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsage +from ...vendored_sdks.azure_quantum_python._client._utils.model_base import _deserialize +from ...vendored_sdks.azure_quantum_python._client.operations._operations import ( + build_services_suite_offers_list_quota_usages_request, +) + + +def _allocation(standard=None, high=None, target_id=None): + ns = SimpleNamespace(standard_minutes_lifetime=standard, high_minutes_lifetime=high) + if target_id is not None: + ns.target_id = target_id + return ns + + +def _offer(provider_id='ionq', location='eastus', quotas=None, target_quotas=None): + properties = SimpleNamespace( + provider_id=provider_id, + location=location, + quotas=quotas, + target_quotas=target_quotas or [], + ) + return SimpleNamespace(properties=properties) + + +def _usage(target_id=None, standard=None, high=None, last_modified_time=None): + return SimpleNamespace( + target_id=target_id, + usage=SimpleNamespace(standard_minutes_lifetime=standard, high_minutes_lifetime=high), + last_modified_time=last_modified_time, + ) class QuantumSuiteOffersScenarioTest(ScenarioTest): @@ -36,6 +70,118 @@ def test_transform_suite_offers(self): self.assertEqual(row['Company'], 'IonQ, Inc.') self.assertEqual(row['Location'], 'eastus') + def test_transform_suite_offer_quotas(self): + quotas = [ + { + 'providerId': 'ionq', + 'scope': 'Subscription', + 'standardMinutesLifetime': {'allocated': 100, 'used': 40, 'remaining': 60}, + 'highMinutesLifetime': {'allocated': 50, 'used': 10, 'remaining': 40}, + } + ] + + table = transform_suite_offer_quotas(quotas) + + self.assertEqual(len(table), 1) + row = table[0] + self.assertEqual(list(row.keys()), [ + 'Scope', 'Target', 'Std Allocated', 'Std Used', 'Std Remaining', + 'High Allocated', 'High Used', 'High Remaining' + ]) + self.assertEqual(row['Scope'], 'Subscription') + self.assertEqual(row['Target'], '') + self.assertEqual(row['Std Allocated'], 100) + self.assertEqual(row['Std Used'], 40) + self.assertEqual(row['Std Remaining'], 60) + self.assertEqual(row['High Allocated'], 50) + + def test_base_url_v2(self): + self.assertEqual(base_url_v2('East US'), 'https://eastus-v2.quantum.azure.com/') + + def test_build_suite_offers_list_quota_usages_request(self): + request = build_services_suite_offers_list_quota_usages_request( + subscription_id='00000000-0000-0000-0000-000000000000', + provider_id='ionq', + ) + self.assertEqual(request.method, 'GET') + self.assertIn( + '/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Quantum/suiteOffers/ionq/quotaUsages', + request.url, + ) + self.assertIn('api-version=2026-01-15-preview', request.url) + + def test_deserialize_quota_usages_bare_array(self): + data = [ + { + 'id': 'usage-1', + 'providerId': 'ionq', + 'scope': 'Subscription', + 'usage': {'standardMinutesLifetime': 40.0, 'highMinutesLifetime': 10.0}, + 'lastModifiedTime': '2026-01-15T00:00:00Z', + }, + { + 'id': 'usage-2', + 'providerId': 'ionq', + 'scope': 'SubscriptionTarget', + 'targetId': 'ionq.qpu', + 'usage': {'standardMinutesLifetime': 5.0, 'highMinutesLifetime': 1.0}, + }, + ] + + usages = _deserialize(list[QuotaUsage], data) + + self.assertEqual(len(usages), 2) + self.assertEqual(usages[0].scope, 'Subscription') + self.assertIsNone(usages[0].target_id) + self.assertEqual(usages[0].usage.standard_minutes_lifetime, 40.0) + self.assertEqual(usages[1].scope, 'SubscriptionTarget') + self.assertEqual(usages[1].target_id, 'ionq.qpu') + + def test_merge_quotas_allocation_and_usage(self): + offer = _offer( + quotas=_allocation(standard=100, high=50), + target_quotas=[_allocation(standard=30, high=None, target_id='ionq.qpu')], + ) + usages = [ + _usage(target_id=None, standard=40, high=10), + _usage(target_id='ionq.qpu', standard=5), + ] + + rows = _merge_suite_offer_quotas(offer, usages, 'ionq') + + self.assertEqual(len(rows), 2) + sub = rows[0] + self.assertEqual(sub['scope'], 'Subscription') + self.assertNotIn('targetId', sub) + self.assertEqual(sub['standardMinutesLifetime'], {'allocated': 100, 'used': 40, 'remaining': 60}) + self.assertEqual(sub['highMinutesLifetime'], {'allocated': 50, 'used': 10, 'remaining': 40}) + + target = rows[1] + self.assertEqual(target['scope'], 'SubscriptionTarget') + self.assertEqual(target['targetId'], 'ionq.qpu') + self.assertEqual(target['standardMinutesLifetime'], {'allocated': 30, 'used': 5, 'remaining': 25}) + # High allocation absent, high usage absent -> omitted entirely. + self.assertNotIn('highMinutesLifetime', target) + + def test_merge_quotas_allocation_only(self): + offer = _offer(quotas=_allocation(standard=100, high=50)) + + rows = _merge_suite_offer_quotas(offer, [], 'ionq') + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['standardMinutesLifetime'], {'allocated': 100, 'used': None, 'remaining': None}) + self.assertNotIn('lastModifiedTime', rows[0]) + + def test_merge_quotas_usage_only(self): + offer = _offer(quotas=None) + usages = [_usage(target_id=None, standard=40, high=10, last_modified_time='2026-01-15T00:00:00Z')] + + rows = _merge_suite_offer_quotas(offer, usages, 'ionq') + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['standardMinutesLifetime'], {'allocated': None, 'used': 40, 'remaining': None}) + self.assertEqual(rows[0]['lastModifiedTime'], '2026-01-15T00:00:00Z') + @live_only() def test_quantum_suite_offer_list(self): offers = self.cmd('az quantum suite-offer list').get_output_in_json() diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py index 64077acdf86..a219fde8471 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py @@ -49,6 +49,7 @@ build_services_sessions_listv2_request, build_services_sessions_open_request, build_services_storage_get_sas_uri_request, + build_services_suite_offers_list_quota_usages_request, build_services_top_level_items_listv2_request, ) from .._configuration import WorkspaceClientConfiguration @@ -81,6 +82,9 @@ def __init__(self, *args, **kwargs) -> None: self.jobs = ServicesJobsOperations(self._client, self._config, self._serialize, self._deserialize) self.providers = ServicesProvidersOperations(self._client, self._config, self._serialize, self._deserialize) self.quotas = ServicesQuotasOperations(self._client, self._config, self._serialize, self._deserialize) + self.suite_offers = ServicesSuiteOffersOperations( + self._client, self._config, self._serialize, self._deserialize + ) self.sessions = ServicesSessionsOperations(self._client, self._config, self._serialize, self._deserialize) self.storage = ServicesStorageOperations(self._client, self._config, self._serialize, self._deserialize) @@ -1200,6 +1204,84 @@ async def get_next(next_link=None): return AsyncItemPaged(get_next, extract_data) +class ServicesSuiteOffersOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~azure.quantum.aio.WorkspaceClient`'s + :attr:`suite_offers` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client: AsyncPipelineClient = input_args.pop(0) if input_args else kwargs.pop("client") + self._config: WorkspaceClientConfiguration = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize: Serializer = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize: Deserializer = input_args.pop(0) if input_args else kwargs.pop("deserializer") + + @distributed_trace_async + async def list_quota_usages( + self, subscription_id: str, provider_id: str, **kwargs: Any + ) -> list["_models.QuotaUsage"]: + """List quota usages for the given suite offer provider account. + + :param subscription_id: The Azure subscription ID. Required. + :type subscription_id: str + :param provider_id: The unique identifier of the suite offer provider account. Required. + :type provider_id: str + :return: list of QuotaUsage + :rtype: list[~azure.quantum.models.QuotaUsage] + :raises ~azure.core.exceptions.HttpResponseError: + """ + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) + + error_map: MutableMapping = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _request = build_services_suite_offers_list_quota_usages_request( + subscription_id=subscription_id, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + _stream = False + pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + response = pipeline_response.http_response + + if response.status_code not in [200]: + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) + + deserialized = response.json() + # The service may return either a bare JSON array or a paged envelope ({"value": [...]}). + if isinstance(deserialized, dict): + deserialized = deserialized.get("value", []) + list_of_elem = _deserialize(list[_models.QuotaUsage], deserialized) + if cls: + return cls(list_of_elem) # type: ignore + return list_of_elem + + class ServicesSessionsOperations: """ .. warning:: diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py index 219a54d2c34..6a0e8d274c4 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py @@ -23,6 +23,8 @@ ProviderStatus, QuantumComputingData, Quota, + QuotaUsage, + QuotaUsageValues, SasUriResponse, SessionDetails, TargetStatus, @@ -58,6 +60,8 @@ "ProviderStatus", "QuantumComputingData", "Quota", + "QuotaUsage", + "QuotaUsageValues", "SasUriResponse", "SessionDetails", "TargetStatus", diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py index b1ae5481cd0..de5caeff09d 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py @@ -531,6 +531,62 @@ class Quota(_Model): 'None' is used for concurrent quotas. Required. Known values are: \"None\" and \"Monthly\".""" +class QuotaUsageValues(_Model): + """The consumed quota usage values, measured in minutes over the lifetime of the provider + account. + + :ivar standard_minutes_lifetime: The amount of standard priority minutes consumed over the + lifetime of the provider account. + :vartype standard_minutes_lifetime: float + :ivar high_minutes_lifetime: The amount of high priority minutes consumed over the lifetime of + the provider account. + :vartype high_minutes_lifetime: float + """ + + standard_minutes_lifetime: Optional[float] = rest_field(name="standardMinutesLifetime", visibility=["read"]) + """The amount of standard priority minutes consumed over the lifetime of the provider account.""" + high_minutes_lifetime: Optional[float] = rest_field(name="highMinutesLifetime", visibility=["read"]) + """The amount of high priority minutes consumed over the lifetime of the provider account.""" + + +class QuotaUsage(_Model): + """Quota usage information for a suite offer provider account. + + :ivar id: The unique identifier of the quota usage record. Required. + :vartype id: str + :ivar provider_id: The unique identifier for the provider account. Required. + :vartype provider_id: str + :ivar scope: The scope at which the quota usage is measured. Required. + :vartype scope: str + :ivar target_id: The identifier of the target the usage applies to, when the scope is + target-specific. + :vartype target_id: str + :ivar usage: The consumed quota usage values. Required. + :vartype usage: ~azure.quantum.models.QuotaUsageValues + :ivar last_modified_time: The timestamp of the last modification of the quota usage record. + :vartype last_modified_time: ~datetime.datetime + :ivar metadata: Additional metadata associated with the quota usage record. + :vartype metadata: dict[str, str] + """ + + id: str = rest_field(visibility=["read"]) + """The unique identifier of the quota usage record. Required.""" + provider_id: str = rest_field(name="providerId", visibility=["read"]) + """The unique identifier for the provider account. Required.""" + scope: str = rest_field(visibility=["read"]) + """The scope at which the quota usage is measured. Required.""" + target_id: Optional[str] = rest_field(name="targetId", visibility=["read"]) + """The identifier of the target the usage applies to, when the scope is target-specific.""" + usage: "_models.QuotaUsageValues" = rest_field(visibility=["read"]) + """The consumed quota usage values. Required.""" + last_modified_time: Optional[datetime.datetime] = rest_field( + name="lastModifiedTime", visibility=["read"], format="rfc3339" + ) + """The timestamp of the last modification of the quota usage record.""" + metadata: Optional[dict[str, str]] = rest_field(visibility=["read"]) + """Additional metadata associated with the quota usage record.""" + + class SasUriResponse(_Model): """SAS URI operation response. diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py index 4f6b49cc250..611a47249e1 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py @@ -332,6 +332,33 @@ def build_services_quotas_list_request( return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) +def build_services_suite_offers_list_quota_usages_request( # pylint: disable=name-too-long + subscription_id: str, provider_id: str, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + api_version: str = kwargs.pop("api_version", _params.pop("api-version", "2026-01-15-preview")) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/subscriptions/{subscriptionId}/providers/Microsoft.Quantum/suiteOffers/{providerId}/quotaUsages" + path_format_arguments = { + "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), + "providerId": _SERIALIZER.url("provider_id", provider_id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct parameters + _params["api-version"] = _SERIALIZER.query("api_version", api_version, "str") + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) + + def build_services_sessions_open_request( subscription_id: str, resource_group_name: str, workspace_name: str, session_id: str, **kwargs: Any ) -> HttpRequest: @@ -566,6 +593,9 @@ def __init__(self, *args, **kwargs) -> None: self.jobs = ServicesJobsOperations(self._client, self._config, self._serialize, self._deserialize) self.providers = ServicesProvidersOperations(self._client, self._config, self._serialize, self._deserialize) self.quotas = ServicesQuotasOperations(self._client, self._config, self._serialize, self._deserialize) + self.suite_offers = ServicesSuiteOffersOperations( + self._client, self._config, self._serialize, self._deserialize + ) self.sessions = ServicesSessionsOperations(self._client, self._config, self._serialize, self._deserialize) self.storage = ServicesStorageOperations(self._client, self._config, self._serialize, self._deserialize) @@ -1685,6 +1715,84 @@ def get_next(next_link=None): return ItemPaged(get_next, extract_data) +class ServicesSuiteOffersOperations: + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~azure.quantum.WorkspaceClient`'s + :attr:`suite_offers` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client: PipelineClient = input_args.pop(0) if input_args else kwargs.pop("client") + self._config: WorkspaceClientConfiguration = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize: Serializer = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize: Deserializer = input_args.pop(0) if input_args else kwargs.pop("deserializer") + + @distributed_trace + def list_quota_usages( + self, subscription_id: str, provider_id: str, **kwargs: Any + ) -> list["_models.QuotaUsage"]: + """List quota usages for the given suite offer provider account. + + :param subscription_id: The Azure subscription ID. Required. + :type subscription_id: str + :param provider_id: The unique identifier of the suite offer provider account. Required. + :type provider_id: str + :return: list of QuotaUsage + :rtype: list[~azure.quantum.models.QuotaUsage] + :raises ~azure.core.exceptions.HttpResponseError: + """ + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) + + error_map: MutableMapping = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _request = build_services_suite_offers_list_quota_usages_request( + subscription_id=subscription_id, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + _stream = False + pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + response = pipeline_response.http_response + + if response.status_code not in [200]: + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) + + deserialized = response.json() + # The service may return either a bare JSON array or a paged envelope ({"value": [...]}). + if isinstance(deserialized, dict): + deserialized = deserialized.get("value", []) + list_of_elem = _deserialize(list[_models.QuotaUsage], deserialized) + if cls: + return cls(list_of_elem) # type: ignore + return list_of_elem + + class ServicesSessionsOperations: """ .. warning:: diff --git a/src/quantum/setup.py b/src/quantum/setup.py index d241880cff5..b6c0526fb41 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.0b25' +VERSION = '1.0.0b26' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From eaeb8f7a6d01e5af5d9ecd53a7cb64d57cde10ea Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Tue, 1 Sep 2026 11:46:20 -0700 Subject: [PATCH 06/24] [Quantum] Address review: target-quota rows with nested allocation/usage output Per review: build one row per targetQuota (SubscriptionTarget scope only), restructure each row into nested 'allocation' and 'usage' blocks, and drop the computed 'remaining' field. --- src/quantum/azext_quantum/_help.py | 6 +- src/quantum/azext_quantum/commands.py | 15 ++-- .../azext_quantum/operations/suite_offers.py | 75 ++++++------------- .../tests/latest/test_quantum_suite_offers.py | 75 ++++++++++--------- 4 files changed, 72 insertions(+), 99 deletions(-) diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index a9c8f12a181..29a5d00336a 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -237,9 +237,9 @@ type: command short-summary: View quota allocations and their consumed usages for a suite offer provider account in the current subscription. long-summary: | - Returns the v2 quota allocations (limits) for the provider account merged with the consumed - usages, for both subscription-level and target-level scopes. Each entry reports the allocated, - used, and remaining standard and high priority minutes over the lifetime of the provider account. + Returns the v2 quota allocations (limits) for each target of the provider account together + with the consumed usages. Each entry reports the allocated and used standard and high priority + minutes over the lifetime of the provider account. examples: - name: View the quota usages for a suite offer provider account. text: |- diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 2c028a217ca..e9614969da7 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -105,22 +105,19 @@ def one(offer): def transform_suite_offer_quotas(quotas): def one(quota): - standard = quota.get('standardMinutesLifetime') or {} - high = quota.get('highMinutesLifetime') or {} + allocation = quota.get('allocation') or {} + usage = quota.get('usage') or {} def cell(source, key): value = source.get(key) return '' if value is None else value return OrderedDict([ - ('Scope', quota.get('scope')), ('Target', quota.get('targetId', '')), - ('Std Allocated', cell(standard, 'allocated')), - ('Std Used', cell(standard, 'used')), - ('Std Remaining', cell(standard, 'remaining')), - ('High Allocated', cell(high, 'allocated')), - ('High Used', cell(high, 'used')), - ('High Remaining', cell(high, 'remaining')) + ('Std Allocated', cell(allocation, 'standardMinutesLifetime')), + ('Std Used', cell(usage, 'standardMinutesLifetime')), + ('High Allocated', cell(allocation, 'highMinutesLifetime')), + ('High Used', cell(usage, 'highMinutesLifetime')) ]) return [one(quota) for quota in quotas] diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py index 93c1ae31382..1db2de86196 100644 --- a/src/quantum/azext_quantum/operations/suite_offers.py +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -57,68 +57,41 @@ def suite_offer_quotas(cmd, provider_id): return _merge_suite_offer_quotas(offer, usages, provider_id) -def _minutes_row(allocated, used): - """Build the per-priority {allocated, used, remaining} entry, or None if nothing is known.""" - if allocated is None and used is None: - return None - remaining = None - if allocated is not None and used is not None: - remaining = allocated - used - return OrderedDict([("allocated", allocated), ("used", used), ("remaining", remaining)]) +def _minutes(standard, high): + """Build a {standardMinutesLifetime, highMinutesLifetime} block.""" + return OrderedDict([ + ("standardMinutesLifetime", standard), + ("highMinutesLifetime", high), + ]) def _merge_suite_offer_quotas(offer, usages, provider_id): """ - Combine the ARM quota allocations (limits) with the data-plane quota usages (consumed), - keyed by target id (None represents the subscription-level allocation). + Build one row per target quota allocation, attaching its matching data-plane usage. + Suite offer quotas are reported at the SubscriptionTarget scope only. """ - props = offer.properties - - # Allocations keyed by target id (None => subscription-level). - allocations = {} - if props.quotas is not None: - allocations[None] = props.quotas - for target_quota in props.target_quotas or []: - allocations[target_quota.target_id] = target_quota - - # Usages keyed by target id (None => subscription-level). - usage_by_key = {} - for usage in usages or []: - usage_by_key[usage.target_id] = usage + # Data-plane usages keyed by target id. + usage_by_target = { + usage.target_id: usage for usage in (usages or []) if usage.target_id is not None + } rows = [] - # Emit the subscription-level row first, then target rows sorted by target id. - keys = list(allocations.keys()) + [k for k in usage_by_key if k not in allocations] - ordered_keys = [None] if (None in allocations or None in usage_by_key) else [] - ordered_keys += sorted(k for k in keys if k is not None) - - for key in ordered_keys: - allocation = allocations.get(key) - usage = usage_by_key.get(key) - - std_allocated = allocation.standard_minutes_lifetime if allocation is not None else None - high_allocated = allocation.high_minutes_lifetime if allocation is not None else None - + for target_quota in sorted(offer.properties.target_quotas or [], key=lambda q: q.target_id or ""): + usage = usage_by_target.get(target_quota.target_id) usage_values = usage.usage if usage is not None else None - std_used = usage_values.standard_minutes_lifetime if usage_values is not None else None - high_used = usage_values.high_minutes_lifetime if usage_values is not None else None row = OrderedDict() row["providerId"] = provider_id - row["scope"] = "Subscription" if key is None else "SubscriptionTarget" - if key is not None: - row["targetId"] = key - - std = _minutes_row(std_allocated, std_used) - if std is not None: - row["standardMinutesLifetime"] = std - high = _minutes_row(high_allocated, high_used) - if high is not None: - row["highMinutesLifetime"] = high - - if usage is not None and usage.last_modified_time is not None: - row["lastModifiedTime"] = usage.last_modified_time - + row["scope"] = "SubscriptionTarget" + row["targetId"] = target_quota.target_id + row["allocation"] = _minutes( + target_quota.standard_minutes_lifetime, + target_quota.high_minutes_lifetime, + ) + row["usage"] = _minutes( + usage_values.standard_minutes_lifetime if usage_values is not None else None, + usage_values.high_minutes_lifetime if usage_values is not None else None, + ) rows.append(row) return rows diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index f23aa016619..824212a26fa 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -74,9 +74,10 @@ def test_transform_suite_offer_quotas(self): quotas = [ { 'providerId': 'ionq', - 'scope': 'Subscription', - 'standardMinutesLifetime': {'allocated': 100, 'used': 40, 'remaining': 60}, - 'highMinutesLifetime': {'allocated': 50, 'used': 10, 'remaining': 40}, + 'scope': 'SubscriptionTarget', + 'targetId': 'ionq.qpu', + 'allocation': {'standardMinutesLifetime': 100, 'highMinutesLifetime': 50}, + 'usage': {'standardMinutesLifetime': 40, 'highMinutesLifetime': 10}, } ] @@ -85,15 +86,13 @@ def test_transform_suite_offer_quotas(self): self.assertEqual(len(table), 1) row = table[0] self.assertEqual(list(row.keys()), [ - 'Scope', 'Target', 'Std Allocated', 'Std Used', 'Std Remaining', - 'High Allocated', 'High Used', 'High Remaining' + 'Target', 'Std Allocated', 'Std Used', 'High Allocated', 'High Used' ]) - self.assertEqual(row['Scope'], 'Subscription') - self.assertEqual(row['Target'], '') + self.assertEqual(row['Target'], 'ionq.qpu') self.assertEqual(row['Std Allocated'], 100) self.assertEqual(row['Std Used'], 40) - self.assertEqual(row['Std Remaining'], 60) self.assertEqual(row['High Allocated'], 50) + self.assertEqual(row['High Used'], 10) def test_base_url_v2(self): self.assertEqual(base_url_v2('East US'), 'https://eastus-v2.quantum.azure.com/') @@ -137,50 +136,54 @@ def test_deserialize_quota_usages_bare_array(self): self.assertEqual(usages[1].scope, 'SubscriptionTarget') self.assertEqual(usages[1].target_id, 'ionq.qpu') - def test_merge_quotas_allocation_and_usage(self): + def test_merge_quotas_target_with_usage(self): offer = _offer( - quotas=_allocation(standard=100, high=50), - target_quotas=[_allocation(standard=30, high=None, target_id='ionq.qpu')], + quotas=_allocation(standard=100, high=50), # subscription-level allocation is ignored + target_quotas=[_allocation(standard=30, high=15, target_id='ionq.qpu')], ) usages = [ - _usage(target_id=None, standard=40, high=10), - _usage(target_id='ionq.qpu', standard=5), + _usage(target_id=None, standard=40, high=10), # subscription-scope usage ignored + _usage(target_id='ionq.qpu', standard=5, high=2), ] rows = _merge_suite_offer_quotas(offer, usages, 'ionq') - self.assertEqual(len(rows), 2) - sub = rows[0] - self.assertEqual(sub['scope'], 'Subscription') - self.assertNotIn('targetId', sub) - self.assertEqual(sub['standardMinutesLifetime'], {'allocated': 100, 'used': 40, 'remaining': 60}) - self.assertEqual(sub['highMinutesLifetime'], {'allocated': 50, 'used': 10, 'remaining': 40}) - - target = rows[1] - self.assertEqual(target['scope'], 'SubscriptionTarget') - self.assertEqual(target['targetId'], 'ionq.qpu') - self.assertEqual(target['standardMinutesLifetime'], {'allocated': 30, 'used': 5, 'remaining': 25}) - # High allocation absent, high usage absent -> omitted entirely. - self.assertNotIn('highMinutesLifetime', target) - - def test_merge_quotas_allocation_only(self): - offer = _offer(quotas=_allocation(standard=100, high=50)) + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(list(row.keys()), ['providerId', 'scope', 'targetId', 'allocation', 'usage']) + self.assertEqual(row['providerId'], 'ionq') + self.assertEqual(row['scope'], 'SubscriptionTarget') + self.assertEqual(row['targetId'], 'ionq.qpu') + self.assertEqual(row['allocation'], {'standardMinutesLifetime': 30, 'highMinutesLifetime': 15}) + self.assertEqual(row['usage'], {'standardMinutesLifetime': 5, 'highMinutesLifetime': 2}) + + def test_merge_quotas_target_without_usage(self): + offer = _offer( + target_quotas=[_allocation(standard=30, high=None, target_id='ionq.qpu')], + ) rows = _merge_suite_offer_quotas(offer, [], 'ionq') self.assertEqual(len(rows), 1) - self.assertEqual(rows[0]['standardMinutesLifetime'], {'allocated': 100, 'used': None, 'remaining': None}) - self.assertNotIn('lastModifiedTime', rows[0]) + row = rows[0] + self.assertEqual(row['allocation'], {'standardMinutesLifetime': 30, 'highMinutesLifetime': None}) + self.assertEqual(row['usage'], {'standardMinutesLifetime': None, 'highMinutesLifetime': None}) - def test_merge_quotas_usage_only(self): - offer = _offer(quotas=None) - usages = [_usage(target_id=None, standard=40, high=10, last_modified_time='2026-01-15T00:00:00Z')] + def test_merge_quotas_ignores_subscription_and_unmatched_usage(self): + offer = _offer( + quotas=_allocation(standard=100, high=50), + target_quotas=[_allocation(standard=30, high=15, target_id='ionq.qpu')], + ) + usages = [ + _usage(target_id=None, standard=40, high=10), # subscription scope -> ignored + _usage(target_id='other.target', standard=7, high=3), # no matching allocation -> ignored + ] rows = _merge_suite_offer_quotas(offer, usages, 'ionq') self.assertEqual(len(rows), 1) - self.assertEqual(rows[0]['standardMinutesLifetime'], {'allocated': None, 'used': 40, 'remaining': None}) - self.assertEqual(rows[0]['lastModifiedTime'], '2026-01-15T00:00:00Z') + self.assertEqual(rows[0]['targetId'], 'ionq.qpu') + self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': None, 'highMinutesLifetime': None}) @live_only() def test_quantum_suite_offer_list(self): From d5193cdfe7cb2f4df309d1ab28be694500386469 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Tue, 1 Sep 2026 12:48:07 -0700 Subject: [PATCH 07/24] [Quantum] Review polish: v2 canary endpoint, quotas live test, doc fixes Non-functional follow-ups from code review: add the canary branch to base_url_v2 for parity with base_url, add a @live_only scenario test for 'suite-offer quotas', correct the 'suite-offer list' help summary, and comment the unused factory args. --- src/quantum/azext_quantum/_client_factory.py | 3 +++ src/quantum/azext_quantum/_help.py | 2 +- .../tests/latest/test_quantum_suite_offers.py | 17 +++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/quantum/azext_quantum/_client_factory.py b/src/quantum/azext_quantum/_client_factory.py index 52752330fec..a1ed6e2aceb 100644 --- a/src/quantum/azext_quantum/_client_factory.py +++ b/src/quantum/azext_quantum/_client_factory.py @@ -31,6 +31,8 @@ def base_url(location): def base_url_v2(location): if 'AZURE_QUANTUM_BASEURL_V2' in os.environ: return os.environ['AZURE_QUANTUM_BASEURL_V2'] + if is_env('canary'): + return "https://eastus2euap-v2.quantum.azure.com/" normalized_location = normalize_location(location) if is_env('dogfood'): return f"https://{normalized_location}-v2.quantum-test.azure.com/" @@ -95,6 +97,7 @@ def cf_quotas(cli_ctx, subscription: str, resource_group: str, ws_name: str, end def cf_suite_offer_quota_usages(cli_ctx, subscription: str, endpoint: str): + # resource_group and workspace name are unused: the data-plane endpoint is supplied directly. return cf_quantum(cli_ctx, subscription, None, None, endpoint).services.suite_offers diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 29a5d00336a..ce68cf4f785 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -223,7 +223,7 @@ helps['quantum suite-offer list'] = """ type: command - short-summary: List the Azure Quantum suite offers available to the current subscription, including provider, location, and subscription-level quota allocations. + short-summary: List the Azure Quantum suite offers available to the current subscription, including provider ID, name, company, and location. examples: - name: List all suite offers available to the current subscription. text: |- diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index 824212a26fa..198ceb78351 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -189,3 +189,20 @@ def test_merge_quotas_ignores_subscription_and_unmatched_usage(self): def test_quantum_suite_offer_list(self): offers = self.cmd('az quantum suite-offer list').get_output_in_json() assert isinstance(offers, list) + + @live_only() + def test_quantum_suite_offer_quotas(self): + offers = self.cmd('az quantum suite-offer list').get_output_in_json() + if not offers: + self.skipTest('No suite offers available in the subscription.') + + provider_id = offers[0]['properties']['providerId'] + quotas = self.cmd(f'az quantum suite-offer quotas -p {provider_id}').get_output_in_json() + + assert isinstance(quotas, list) + for row in quotas: + self.assertEqual(set(row.keys()), {'providerId', 'scope', 'targetId', 'allocation', 'usage'}) + self.assertEqual(row['scope'], 'SubscriptionTarget') + self.assertEqual(row['providerId'], provider_id) + self.assertEqual(set(row['allocation'].keys()), {'standardMinutesLifetime', 'highMinutesLifetime'}) + self.assertEqual(set(row['usage'].keys()), {'standardMinutesLifetime', 'highMinutesLifetime'}) From 90a027f84f69104c673eee95342b5e0e91b0d625 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Wed, 2 Sep 2026 10:53:53 -0700 Subject: [PATCH 08/24] [Quantum] Add 'az quantum suite-offer target list' command Lists targets and their status for a suite offer provider account via the data plane, without requiring a workspace. Fixes single-object ProviderStatus parsing, consolidates the data-plane suite-offer client factory, and bumps the extension to 1.0.0b27. --- src/quantum/HISTORY.rst | 4 + src/quantum/azext_quantum/_client_factory.py | 2 +- src/quantum/azext_quantum/_help.py | 21 ++++ src/quantum/azext_quantum/_params.py | 3 + src/quantum/azext_quantum/commands.py | 3 + .../azext_quantum/operations/suite_offers.py | 36 +++++- .../tests/latest/test_quantum_suite_offers.py | 111 +++++++++++++++++- .../_client/aio/operations/_operations.py | 62 ++++++++++ .../_client/operations/_operations.py | 88 ++++++++++++++ src/quantum/setup.py | 2 +- 10 files changed, 326 insertions(+), 6 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index 88b287f48c5..e6ca4cc5de3 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +1.0.0b27 +++++++++++++++ +* Added the ``az quantum suite-offer target list`` command to list the targets and their status available through a suite offer provider account, without requiring a workspace. + 1.0.0b26 ++++++++++++++ * Added the ``az quantum suite-offer quotas`` command to view quota allocations merged with their consumed usages for a suite offer provider account in the subscription. diff --git a/src/quantum/azext_quantum/_client_factory.py b/src/quantum/azext_quantum/_client_factory.py index a1ed6e2aceb..8f8e596d2b3 100644 --- a/src/quantum/azext_quantum/_client_factory.py +++ b/src/quantum/azext_quantum/_client_factory.py @@ -96,7 +96,7 @@ 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_suite_offer_quota_usages(cli_ctx, subscription: str, endpoint: str): +def cf_suite_offers_data_plane(cli_ctx, subscription: str, endpoint: str): # resource_group and workspace name are unused: the data-plane endpoint is supplied directly. return cf_quantum(cli_ctx, subscription, None, None, endpoint).services.suite_offers diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index ce68cf4f785..0e8e39b497e 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -249,6 +249,27 @@ az quantum suite-offer quotas -p MyProviderAccount """ +helps['quantum suite-offer target'] = """ + type: group + short-summary: List targets available through an Azure Quantum suite offer. +""" + +helps['quantum suite-offer target list'] = """ + type: command + short-summary: List the targets and their status available through a suite offer, without requiring a workspace. + long-summary: | + Returns each target exposed by the suite offer provider account together with its current + availability and average queue time, resolved directly from the data plane without requiring + an Azure Quantum workspace. + examples: + - name: List the targets available in a suite offer. + text: |- + az quantum suite-offer target list --provider-id MyProviderAccount -o table + - name: List the raw target status details for a suite offer provider account. + text: |- + az quantum suite-offer target list -p MyProviderAccount +""" + helps['quantum offerings'] = """ type: group short-summary: Manage provider offerings for Azure Quantum. diff --git a/src/quantum/azext_quantum/_params.py b/src/quantum/azext_quantum/_params.py index 877db2e75a4..283b8454581 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -319,3 +319,6 @@ def load_arguments(self, _): # pylint: disable=too-many-locals with self.argument_context('quantum suite-offer quotas') as c: c.argument('provider_id', provider_id_type, required=True) + + with self.argument_context('quantum suite-offer target list') as c: + c.argument('provider_id', provider_id_type, required=True) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index e9614969da7..63ac69d79ab 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -227,3 +227,6 @@ def load_command_table(self, _): with self.command_group('quantum suite-offer', suite_offers_ops) as s: s.command('list', 'list_suite_offers', table_transformer=transform_suite_offers) s.command('quotas', 'suite_offer_quotas', table_transformer=transform_suite_offer_quotas) + + with self.command_group('quantum suite-offer target', suite_offers_ops) as st: + st.command('list', 'suite_offer_targets', table_transformer=transform_targets) diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py index 1db2de86196..22f6346a157 100644 --- a/src/quantum/azext_quantum/operations/suite_offers.py +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -11,7 +11,7 @@ from azure.cli.core.commands.client_factory import get_subscription_id from azure.core.exceptions import ResourceNotFoundError as AzureResourceNotFoundError -from .._client_factory import cf_suite_offers, cf_suite_offer_quota_usages, base_url_v2 +from .._client_factory import cf_suite_offers, cf_suite_offers_data_plane, base_url_v2 def list_suite_offers(cmd): @@ -45,7 +45,7 @@ def suite_offer_quotas(cmd, provider_id): # 2. Data-plane (v2): fetch the consumed quota usages for that provider account. endpoint = base_url_v2(offer.properties.location) - client = cf_suite_offer_quota_usages(cmd.cli_ctx, subscription_id, endpoint) + client = cf_suite_offers_data_plane(cmd.cli_ctx, subscription_id, endpoint) try: usages = client.list_quota_usages(subscription_id, provider_id) except AzureResourceNotFoundError as ex: @@ -57,6 +57,38 @@ def suite_offer_quotas(cmd, provider_id): return _merge_suite_offer_quotas(offer, usages, provider_id) +def suite_offer_targets(cmd, provider_id): + """ + List the targets and their status available through a suite offer provider account, + without requiring an Azure Quantum workspace. + """ + subscription_id = get_subscription_id(cmd.cli_ctx) + + # 1. Control-plane: locate the suite offer to resolve its region. + offers = cf_suite_offers(cmd.cli_ctx).list_by_subscription() + offer = next( + (o for o in offers + if o.properties is not None + and o.properties.provider_id is not None + and o.properties.provider_id.lower() == provider_id.lower()), + None, + ) + if offer is None: + raise InvalidArgumentValueError( + f"No suite offer was found for provider account '{provider_id}' in subscription '{subscription_id}'." + ) + + # 2. Data-plane (v2): fetch the provider/target statuses for that provider account. + endpoint = base_url_v2(offer.properties.location) + client = cf_suite_offers_data_plane(cmd.cli_ctx, subscription_id, endpoint) + try: + return client.list_provider_status(subscription_id, provider_id) + except AzureResourceNotFoundError as ex: + raise ResourceNotFoundError( + f"No target status was found for provider account '{provider_id}'." + ) from ex + + def _minutes(standard, high): """Build a {standardMinutesLifetime, highMinutesLifetime} block.""" return OrderedDict([ diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index 198ceb78351..4a21424b1c7 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -8,13 +8,15 @@ from azure.cli.testsdk.scenario_tests import live_only from azure.cli.testsdk import ScenarioTest -from ...commands import transform_suite_offers, transform_suite_offer_quotas +from ...commands import transform_suite_offers, transform_suite_offer_quotas, transform_targets from ..._client_factory import base_url_v2 from ...operations.suite_offers import _merge_suite_offer_quotas -from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsage +from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsage, ProviderStatus from ...vendored_sdks.azure_quantum_python._client._utils.model_base import _deserialize from ...vendored_sdks.azure_quantum_python._client.operations._operations import ( build_services_suite_offers_list_quota_usages_request, + build_services_suite_offers_list_provider_status_request, + ServicesSuiteOffersOperations, ) @@ -136,6 +138,93 @@ def test_deserialize_quota_usages_bare_array(self): self.assertEqual(usages[1].scope, 'SubscriptionTarget') self.assertEqual(usages[1].target_id, 'ionq.qpu') + def test_build_suite_offers_list_provider_status_request(self): + request = build_services_suite_offers_list_provider_status_request( + subscription_id='00000000-0000-0000-0000-000000000000', + provider_id='ionq', + ) + self.assertEqual(request.method, 'GET') + self.assertIn( + '/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Quantum/suiteOffers/ionq/providerStatus', + request.url, + ) + self.assertIn('api-version=2026-01-15-preview', request.url) + + def test_deserialize_provider_status_bare_array(self): + data = [ + { + 'id': 'ionq', + 'currentAvailability': 'Available', + 'targets': [ + {'id': 'ionq.qpu', 'currentAvailability': 'Available', 'averageQueueTime': 42}, + ], + } + ] + + providers = _deserialize(list[ProviderStatus], data) + + self.assertEqual(len(providers), 1) + self.assertEqual(providers[0].id, 'ionq') + self.assertEqual(providers[0].current_availability, 'Available') + self.assertEqual(len(providers[0].targets), 1) + self.assertEqual(providers[0].targets[0].id, 'ionq.qpu') + self.assertEqual(providers[0].targets[0].average_queue_time, 42) + + def test_list_provider_status_wraps_single_object(self): + # The service returns a single ProviderStatus object, not a paged envelope or array. + single = { + 'id': 'ionq', + 'currentAvailability': 'Available', + 'targets': [ + {'id': 'ionq.qpu', 'currentAvailability': 'Available', 'averageQueueTime': 7}, + ], + } + http_response = SimpleNamespace(status_code=200, json=lambda: single) + pipeline_response = SimpleNamespace(http_response=http_response) + fake_client = SimpleNamespace( + _pipeline=SimpleNamespace(run=lambda request, **kwargs: pipeline_response), + format_url=lambda url, **kwargs: url, + ) + fake_config = SimpleNamespace(api_version='2026-01-15-preview', endpoint='https://example') + fake_serialize = SimpleNamespace(url=lambda name, value, kind, **kwargs: value) + + operations = ServicesSuiteOffersOperations( + fake_client, fake_config, fake_serialize, object() + ) + + result = operations.list_provider_status( + '00000000-0000-0000-0000-000000000000', 'ionq' + ) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].id, 'ionq') + self.assertEqual(result[0].current_availability, 'Available') + self.assertEqual(result[0].targets[0].id, 'ionq.qpu') + self.assertEqual(result[0].targets[0].average_queue_time, 7) + + def test_transform_targets_suite_offer_shape(self): + providers = [ + { + 'id': 'ionq', + 'currentAvailability': 'Available', + 'targets': [ + {'id': 'ionq.qpu', 'currentAvailability': 'Available', 'averageQueueTime': 42}, + ], + } + ] + + table = transform_targets(providers) + + self.assertEqual(len(table), 1) + row = table[0] + self.assertEqual(list(row.keys()), [ + 'Provider', 'Target-id', 'Current Availability', 'Average Queue Time (seconds)' + ]) + self.assertEqual(row['Provider'], 'ionq') + self.assertEqual(row['Target-id'], 'ionq.qpu') + self.assertEqual(row['Current Availability'], 'Available') + self.assertEqual(row['Average Queue Time (seconds)'], 42) + def test_merge_quotas_target_with_usage(self): offer = _offer( quotas=_allocation(standard=100, high=50), # subscription-level allocation is ignored @@ -206,3 +295,21 @@ def test_quantum_suite_offer_quotas(self): self.assertEqual(row['providerId'], provider_id) self.assertEqual(set(row['allocation'].keys()), {'standardMinutesLifetime', 'highMinutesLifetime'}) self.assertEqual(set(row['usage'].keys()), {'standardMinutesLifetime', 'highMinutesLifetime'}) + + @live_only() + def test_quantum_suite_offer_target_list(self): + offers = self.cmd('az quantum suite-offer list').get_output_in_json() + if not offers: + self.skipTest('No suite offers available in the subscription.') + + provider_id = offers[0]['properties']['providerId'] + providers = self.cmd(f'az quantum suite-offer target list -p {provider_id}').get_output_in_json() + + assert isinstance(providers, list) + for provider in providers: + self.assertIn('id', provider) + self.assertIn('targets', provider) + for target in provider['targets']: + self.assertIn('id', target) + self.assertIn('currentAvailability', target) + self.assertIn('averageQueueTime', target) diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py index a219fde8471..ac3de882d24 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py @@ -49,6 +49,7 @@ build_services_sessions_listv2_request, build_services_sessions_open_request, build_services_storage_get_sas_uri_request, + build_services_suite_offers_list_provider_status_request, build_services_suite_offers_list_quota_usages_request, build_services_top_level_items_listv2_request, ) @@ -1281,6 +1282,67 @@ async def list_quota_usages( return cls(list_of_elem) # type: ignore return list_of_elem + @distributed_trace_async + async def list_provider_status( + self, subscription_id: str, provider_id: str, **kwargs: Any + ) -> list["_models.ProviderStatus"]: + """List the target statuses for the given suite offer provider account. + + :param subscription_id: The Azure subscription ID. Required. + :type subscription_id: str + :param provider_id: The unique identifier of the suite offer provider account. Required. + :type provider_id: str + :return: list of ProviderStatus + :rtype: list[~azure.quantum.models.ProviderStatus] + :raises ~azure.core.exceptions.HttpResponseError: + """ + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[list[_models.ProviderStatus]] = kwargs.pop("cls", None) + + error_map: MutableMapping = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _request = build_services_suite_offers_list_provider_status_request( + subscription_id=subscription_id, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + _stream = False + pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + response = pipeline_response.http_response + + if response.status_code not in [200]: + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) + + deserialized = response.json() + # The provider status endpoint returns a single ProviderStatus object (not a list or a + # paged envelope), so wrap it in a list. A paged envelope or bare array is tolerated too. + if isinstance(deserialized, dict): + deserialized = deserialized.get("value", [deserialized]) + list_of_elem = _deserialize(list[_models.ProviderStatus], deserialized) + if cls: + return cls(list_of_elem) # type: ignore + return list_of_elem + class ServicesSessionsOperations: """ diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py index 611a47249e1..4873ae2d1b3 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py @@ -359,6 +359,33 @@ def build_services_suite_offers_list_quota_usages_request( # pylint: disable=na return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) +def build_services_suite_offers_list_provider_status_request( # pylint: disable=name-too-long + subscription_id: str, provider_id: str, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + api_version: str = kwargs.pop("api_version", _params.pop("api-version", "2026-01-15-preview")) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/subscriptions/{subscriptionId}/providers/Microsoft.Quantum/suiteOffers/{providerId}/providerStatus" + path_format_arguments = { + "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), + "providerId": _SERIALIZER.url("provider_id", provider_id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct parameters + _params["api-version"] = _SERIALIZER.query("api_version", api_version, "str") + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) + + def build_services_sessions_open_request( subscription_id: str, resource_group_name: str, workspace_name: str, session_id: str, **kwargs: Any ) -> HttpRequest: @@ -1792,6 +1819,67 @@ def list_quota_usages( return cls(list_of_elem) # type: ignore return list_of_elem + @distributed_trace + def list_provider_status( + self, subscription_id: str, provider_id: str, **kwargs: Any + ) -> list["_models.ProviderStatus"]: + """List the target statuses for the given suite offer provider account. + + :param subscription_id: The Azure subscription ID. Required. + :type subscription_id: str + :param provider_id: The unique identifier of the suite offer provider account. Required. + :type provider_id: str + :return: list of ProviderStatus + :rtype: list[~azure.quantum.models.ProviderStatus] + :raises ~azure.core.exceptions.HttpResponseError: + """ + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[list[_models.ProviderStatus]] = kwargs.pop("cls", None) + + error_map: MutableMapping = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _request = build_services_suite_offers_list_provider_status_request( + subscription_id=subscription_id, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + _stream = False + pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + response = pipeline_response.http_response + + if response.status_code not in [200]: + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) + + deserialized = response.json() + # The provider status endpoint returns a single ProviderStatus object (not a list or a + # paged envelope), so wrap it in a list. A paged envelope or bare array is tolerated too. + if isinstance(deserialized, dict): + deserialized = deserialized.get("value", [deserialized]) + list_of_elem = _deserialize(list[_models.ProviderStatus], deserialized) + if cls: + return cls(list_of_elem) # type: ignore + return list_of_elem + class ServicesSessionsOperations: """ diff --git a/src/quantum/setup.py b/src/quantum/setup.py index b6c0526fb41..bbac3ca69e6 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.0b26' +VERSION = '1.0.0b27' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From d8766b5195dbf76f4894eae179cc9407b96ab7bc Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Wed, 2 Sep 2026 12:05:44 -0700 Subject: [PATCH 09/24] [Quantum] Return single ProviderStatus for suite-offer target status Address review: the data-plane getProviderStatus endpoint returns a single ProviderStatus object per the spec, not a list. Rename the vendored list_provider_status to get_provider_status (sync + async) returning a single ProviderStatus, drop the wrap-in-list workaround, and have the target-list handler wrap the result for the shared table transformer. Update tests accordingly. --- .../azext_quantum/operations/suite_offers.py | 8 ++- .../tests/latest/test_quantum_suite_offers.py | 50 +++++++++---------- .../_client/aio/operations/_operations.py | 28 +++++------ .../_client/operations/_operations.py | 28 +++++------ 4 files changed, 53 insertions(+), 61 deletions(-) diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py index 22f6346a157..406b63b1dc9 100644 --- a/src/quantum/azext_quantum/operations/suite_offers.py +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -78,16 +78,20 @@ def suite_offer_targets(cmd, provider_id): f"No suite offer was found for provider account '{provider_id}' in subscription '{subscription_id}'." ) - # 2. Data-plane (v2): fetch the provider/target statuses for that provider account. + # 2. Data-plane (v2): fetch the provider/target status for that provider account. endpoint = base_url_v2(offer.properties.location) client = cf_suite_offers_data_plane(cmd.cli_ctx, subscription_id, endpoint) try: - return client.list_provider_status(subscription_id, provider_id) + status = client.get_provider_status(subscription_id, provider_id) except AzureResourceNotFoundError as ex: raise ResourceNotFoundError( f"No target status was found for provider account '{provider_id}'." ) from ex + # The endpoint returns a single provider; wrap it so the table transformer shared with + # 'az quantum target list' can iterate provider rows. + return [status] + def _minutes(standard, high): """Build a {standardMinutesLifetime, highMinutesLifetime} block.""" diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index 4a21424b1c7..c4404ee5226 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -15,7 +15,7 @@ from ...vendored_sdks.azure_quantum_python._client._utils.model_base import _deserialize from ...vendored_sdks.azure_quantum_python._client.operations._operations import ( build_services_suite_offers_list_quota_usages_request, - build_services_suite_offers_list_provider_status_request, + build_services_suite_offers_get_provider_status_request, ServicesSuiteOffersOperations, ) @@ -138,8 +138,8 @@ def test_deserialize_quota_usages_bare_array(self): self.assertEqual(usages[1].scope, 'SubscriptionTarget') self.assertEqual(usages[1].target_id, 'ionq.qpu') - def test_build_suite_offers_list_provider_status_request(self): - request = build_services_suite_offers_list_provider_status_request( + def test_build_suite_offers_get_provider_status_request(self): + request = build_services_suite_offers_get_provider_status_request( subscription_id='00000000-0000-0000-0000-000000000000', provider_id='ionq', ) @@ -150,27 +150,24 @@ def test_build_suite_offers_list_provider_status_request(self): ) self.assertIn('api-version=2026-01-15-preview', request.url) - def test_deserialize_provider_status_bare_array(self): - data = [ - { - 'id': 'ionq', - 'currentAvailability': 'Available', - 'targets': [ - {'id': 'ionq.qpu', 'currentAvailability': 'Available', 'averageQueueTime': 42}, - ], - } - ] + def test_deserialize_provider_status_single_object(self): + data = { + 'id': 'ionq', + 'currentAvailability': 'Available', + 'targets': [ + {'id': 'ionq.qpu', 'currentAvailability': 'Available', 'averageQueueTime': 42}, + ], + } - providers = _deserialize(list[ProviderStatus], data) + provider = _deserialize(ProviderStatus, data) - self.assertEqual(len(providers), 1) - self.assertEqual(providers[0].id, 'ionq') - self.assertEqual(providers[0].current_availability, 'Available') - self.assertEqual(len(providers[0].targets), 1) - self.assertEqual(providers[0].targets[0].id, 'ionq.qpu') - self.assertEqual(providers[0].targets[0].average_queue_time, 42) + self.assertEqual(provider.id, 'ionq') + self.assertEqual(provider.current_availability, 'Available') + self.assertEqual(len(provider.targets), 1) + self.assertEqual(provider.targets[0].id, 'ionq.qpu') + self.assertEqual(provider.targets[0].average_queue_time, 42) - def test_list_provider_status_wraps_single_object(self): + def test_get_provider_status_returns_single_object(self): # The service returns a single ProviderStatus object, not a paged envelope or array. single = { 'id': 'ionq', @@ -192,15 +189,14 @@ def test_list_provider_status_wraps_single_object(self): fake_client, fake_config, fake_serialize, object() ) - result = operations.list_provider_status( + result = operations.get_provider_status( '00000000-0000-0000-0000-000000000000', 'ionq' ) - self.assertEqual(len(result), 1) - self.assertEqual(result[0].id, 'ionq') - self.assertEqual(result[0].current_availability, 'Available') - self.assertEqual(result[0].targets[0].id, 'ionq.qpu') - self.assertEqual(result[0].targets[0].average_queue_time, 7) + self.assertEqual(result.id, 'ionq') + self.assertEqual(result.current_availability, 'Available') + self.assertEqual(result.targets[0].id, 'ionq.qpu') + self.assertEqual(result.targets[0].average_queue_time, 7) def test_transform_targets_suite_offer_shape(self): providers = [ diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py index ac3de882d24..8340a21a72f 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py @@ -49,7 +49,7 @@ build_services_sessions_listv2_request, build_services_sessions_open_request, build_services_storage_get_sas_uri_request, - build_services_suite_offers_list_provider_status_request, + build_services_suite_offers_get_provider_status_request, build_services_suite_offers_list_quota_usages_request, build_services_top_level_items_listv2_request, ) @@ -1283,23 +1283,23 @@ async def list_quota_usages( return list_of_elem @distributed_trace_async - async def list_provider_status( + async def get_provider_status( self, subscription_id: str, provider_id: str, **kwargs: Any - ) -> list["_models.ProviderStatus"]: - """List the target statuses for the given suite offer provider account. + ) -> "_models.ProviderStatus": + """Get the target status for the given suite offer provider account. :param subscription_id: The Azure subscription ID. Required. :type subscription_id: str :param provider_id: The unique identifier of the suite offer provider account. Required. :type provider_id: str - :return: list of ProviderStatus - :rtype: list[~azure.quantum.models.ProviderStatus] + :return: ProviderStatus + :rtype: ~azure.quantum.models.ProviderStatus :raises ~azure.core.exceptions.HttpResponseError: """ _headers = kwargs.pop("headers", {}) or {} _params = kwargs.pop("params", {}) or {} - cls: ClsType[list[_models.ProviderStatus]] = kwargs.pop("cls", None) + cls: ClsType[_models.ProviderStatus] = kwargs.pop("cls", None) error_map: MutableMapping = { 401: ClientAuthenticationError, @@ -1309,7 +1309,7 @@ async def list_provider_status( } error_map.update(kwargs.pop("error_map", {}) or {}) - _request = build_services_suite_offers_list_provider_status_request( + _request = build_services_suite_offers_get_provider_status_request( subscription_id=subscription_id, provider_id=provider_id, api_version=self._config.api_version, @@ -1333,15 +1333,11 @@ async def list_provider_status( map_error(status_code=response.status_code, response=response, error_map=error_map) raise HttpResponseError(response=response) - deserialized = response.json() - # The provider status endpoint returns a single ProviderStatus object (not a list or a - # paged envelope), so wrap it in a list. A paged envelope or bare array is tolerated too. - if isinstance(deserialized, dict): - deserialized = deserialized.get("value", [deserialized]) - list_of_elem = _deserialize(list[_models.ProviderStatus], deserialized) + # The provider status endpoint returns a single ProviderStatus object. + deserialized = _deserialize(_models.ProviderStatus, response.json()) if cls: - return cls(list_of_elem) # type: ignore - return list_of_elem + return cls(deserialized) # type: ignore + return deserialized class ServicesSessionsOperations: diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py index 4873ae2d1b3..99e28e36f7e 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py @@ -359,7 +359,7 @@ def build_services_suite_offers_list_quota_usages_request( # pylint: disable=na return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) -def build_services_suite_offers_list_provider_status_request( # pylint: disable=name-too-long +def build_services_suite_offers_get_provider_status_request( # pylint: disable=name-too-long subscription_id: str, provider_id: str, **kwargs: Any ) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) @@ -1820,23 +1820,23 @@ def list_quota_usages( return list_of_elem @distributed_trace - def list_provider_status( + def get_provider_status( self, subscription_id: str, provider_id: str, **kwargs: Any - ) -> list["_models.ProviderStatus"]: - """List the target statuses for the given suite offer provider account. + ) -> "_models.ProviderStatus": + """Get the target status for the given suite offer provider account. :param subscription_id: The Azure subscription ID. Required. :type subscription_id: str :param provider_id: The unique identifier of the suite offer provider account. Required. :type provider_id: str - :return: list of ProviderStatus - :rtype: list[~azure.quantum.models.ProviderStatus] + :return: ProviderStatus + :rtype: ~azure.quantum.models.ProviderStatus :raises ~azure.core.exceptions.HttpResponseError: """ _headers = kwargs.pop("headers", {}) or {} _params = kwargs.pop("params", {}) or {} - cls: ClsType[list[_models.ProviderStatus]] = kwargs.pop("cls", None) + cls: ClsType[_models.ProviderStatus] = kwargs.pop("cls", None) error_map: MutableMapping = { 401: ClientAuthenticationError, @@ -1846,7 +1846,7 @@ def list_provider_status( } error_map.update(kwargs.pop("error_map", {}) or {}) - _request = build_services_suite_offers_list_provider_status_request( + _request = build_services_suite_offers_get_provider_status_request( subscription_id=subscription_id, provider_id=provider_id, api_version=self._config.api_version, @@ -1870,15 +1870,11 @@ def list_provider_status( map_error(status_code=response.status_code, response=response, error_map=error_map) raise HttpResponseError(response=response) - deserialized = response.json() - # The provider status endpoint returns a single ProviderStatus object (not a list or a - # paged envelope), so wrap it in a list. A paged envelope or bare array is tolerated too. - if isinstance(deserialized, dict): - deserialized = deserialized.get("value", [deserialized]) - list_of_elem = _deserialize(list[_models.ProviderStatus], deserialized) + # The provider status endpoint returns a single ProviderStatus object. + deserialized = _deserialize(_models.ProviderStatus, response.json()) if cls: - return cls(list_of_elem) # type: ignore - return list_of_elem + return cls(deserialized) # type: ignore + return deserialized class ServicesSessionsOperations: From 0cefd7d7147f7f483c27c9a85cc14f08b1b04562 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Wed, 2 Sep 2026 12:54:54 -0700 Subject: [PATCH 10/24] [Quantum] Update 'az quantum workspace quotas' to return v2 target quota allocations merged with usages Replaces the legacy data-plane quotas listing with v2 workspace target quota allocations (from ARM) merged with their consumed quota usages from the data-plane v2 quotaUsages endpoint. The workspace quotaUsages endpoint requires a providerId query parameter, so usages are fetched per provider. Adds a table transformer and unit tests. --- src/quantum/HISTORY.rst | 1 + src/quantum/azext_quantum/_help.py | 10 +- src/quantum/azext_quantum/commands.py | 23 ++- .../azext_quantum/operations/workspace.py | 84 +++++++++- .../tests/latest/test_quantum_workspace.py | 154 +++++++++++++++++- .../_client/aio/operations/_operations.py | 67 ++++++++ .../_client/operations/_operations.py | 95 +++++++++++ 7 files changed, 420 insertions(+), 14 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index e6ca4cc5de3..ac5294d5c8c 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -6,6 +6,7 @@ Release History 1.0.0b27 ++++++++++++++ * Added the ``az quantum suite-offer target list`` command to list the targets and their status available through a suite offer provider account, without requiring a workspace. +* Updated the ``az quantum workspace quotas`` command to return v2 workspace target quota allocations merged with their consumed usages. 1.0.0b26 ++++++++++++++ diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 0e8e39b497e..5f6062522a6 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -406,11 +406,15 @@ helps['quantum workspace quotas'] = """ type: command - short-summary: List the quotas for the given (or current) Azure Quantum workspace. + short-summary: List the v2 target quota allocations, merged with their consumed usages, for an Azure Quantum workspace. + long-summary: | + Returns the target quota allocations (limits) configured on the workspace together with the + consumed usages reported by the data plane. Each entry reports the allocated and used standard + and high priority minutes over the lifetime of the workspace. examples: - - name: List the quota information of a specified Azure Quantum workspace. If a default workspace has been set, the -g and -w parameters are not required. + - name: View the quota usages for the given (or current) workspace. If a default workspace has been set, the -g and -w parameters are not required. text: |- - az quantum workspace quotas -g MyResourceGroup -w MyWorkspace + az quantum workspace quotas -g MyResourceGroup -w MyWorkspace -o table """ helps['quantum workspace set'] = """ diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 63ac69d79ab..d121a891d93 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -123,6 +123,27 @@ def cell(source, key): return [one(quota) for quota in quotas] +def transform_workspace_quotas(quotas): + def one(quota): + allocation = quota.get('allocation') or {} + usage = quota.get('usage') or {} + + def cell(source, key): + value = source.get(key) + return '' if value is None else value + + return OrderedDict([ + ('Provider', quota.get('providerId', '')), + ('Target', quota.get('targetId', '')), + ('Std Allocated', cell(allocation, 'standardMinutesLifetime')), + ('Std Used', cell(usage, 'standardMinutesLifetime')), + ('High Allocated', cell(allocation, 'highMinutesLifetime')), + ('High Used', cell(usage, 'highMinutesLifetime')) + ]) + + return [one(quota) for quota in quotas] + + def transform_output(results): def one(key, value): repeat = round(20 * value) @@ -187,7 +208,7 @@ def load_command_table(self, _): w.show_command('show', validator=validate_workspace_info) w.command('set', 'set', validator=validate_workspace_info) w.command('clear', 'clear') - w.command('quotas', 'quotas', validator=validate_workspace_info) + w.command('quotas', 'quotas', validator=validate_workspace_info, table_transformer=transform_workspace_quotas) w.command('keys list', 'list_keys') w.command('keys regenerate', 'regenerate_keys') w.command('update', 'update') diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 105433c9ff1..77d9f72406e 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -11,6 +11,7 @@ import time from builtins import set as builtin_set +from collections import OrderedDict from azure.cli.command_modules.storage.operations.account import list_storage_accounts @@ -21,9 +22,9 @@ ClientRequestError, ForbiddenError, UnauthorizedError, RequiredArgumentMissingError, ResourceNotFoundError, MutuallyExclusiveArgumentError) +from azure.core.exceptions import ResourceNotFoundError as AzureResourceNotFoundError -from .._client_factory import cf_workspaces, cf_quotas, cf_offerings, _get_data_credentials -from .._list_helper import repack_response_json +from .._client_factory import cf_workspaces, cf_quotas, cf_offerings, _get_data_credentials, base_url_v2 from ..vendored_sdks.azure_mgmt_quantum.models import QuantumWorkspace from ..vendored_sdks.azure_mgmt_quantum.models import ManagedServiceIdentity from ..vendored_sdks.azure_mgmt_quantum.models import Provider, ApiKeys, WorkspaceResourceProperties, KeyType, TargetQuotaAllocations @@ -444,12 +445,83 @@ def get(cmd, resource_group_name=None, workspace_name=None): def quotas(cmd, resource_group_name, workspace_name): """ - List the quotas for the given (or current) Azure Quantum workspace. + List the v2 target quota allocations for the given (or current) Azure Quantum workspace, + merged with their consumed usages. """ info = WorkspaceInfo(cmd, resource_group_name, workspace_name) - client = cf_quotas(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) + + # 1. Control-plane: fetch the workspace for its target quota allocations and region. + workspace = cf_workspaces(cmd.cli_ctx).get(info.resource_group, info.name) + + properties = workspace.properties + providers = properties.providers if properties is not None else None + + # 2. Data-plane (v2): fetch the consumed quota usages per provider in the workspace. + # The workspace quotaUsages endpoint requires a providerId, so query each provider. + endpoint = base_url_v2(workspace.location) + client = cf_quotas(cmd.cli_ctx, info.subscription, info.resource_group, info.name, endpoint) + + usages = [] + for provider in providers or []: + # Only providers with target quota allocations produce rows, so skip the rest. + if not provider.target_quotas: + continue + try: + provider_usages = client.list_quota_usages( + info.subscription, info.resource_group, info.name, provider.provider_id) + except AzureResourceNotFoundError: + # No usages have been recorded yet for this provider; report allocations only. + provider_usages = None + usages.extend(provider_usages or []) + + # 3. Merge allocations (limits) with usages (consumed). + return _merge_workspace_quotas(workspace, usages) + + +def _quota_minutes(standard, high): + """Build a {standardMinutesLifetime, highMinutesLifetime} block.""" + return OrderedDict([ + ("standardMinutesLifetime", standard), + ("highMinutesLifetime", high), + ]) + + +def _merge_workspace_quotas(workspace, usages): + """ + Build one row per provider target quota allocation, attaching its matching data-plane usage. + Workspace quotas are reported at the WorkspaceTarget scope. + """ + # Data-plane usages keyed by (provider id, target id). + usage_by_key = { + (usage.provider_id, usage.target_id): usage + for usage in (usages or []) + if usage.target_id is not None + } + + properties = workspace.properties + providers = properties.providers if properties is not None else None + + rows = [] + for provider in sorted(providers or [], key=lambda p: p.provider_id or ""): + for target_quota in sorted(provider.target_quotas or [], key=lambda q: q.target_id or ""): + usage = usage_by_key.get((provider.provider_id, target_quota.target_id)) + usage_values = usage.usage if usage is not None else None + + row = OrderedDict() + row["providerId"] = provider.provider_id + row["scope"] = "WorkspaceTarget" + row["targetId"] = target_quota.target_id + row["allocation"] = _quota_minutes( + target_quota.standard_minutes_lifetime, + target_quota.high_minutes_lifetime, + ) + row["usage"] = _quota_minutes( + usage_values.standard_minutes_lifetime if usage_values is not None else None, + usage_values.high_minutes_lifetime if usage_values is not None else None, + ) + rows.append(row) + + return rows def set(cmd, workspace_name, resource_group_name): diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 19509ab0638..578586ecf7f 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -21,7 +21,12 @@ from datetime import datetime from ...__init__ import CLI_REPORTED_VERSION from ...operations.workspace import _apply_target_quotas, _require_v2_workspace, _validate_storage_account, _autoadd_providers, list_users, update, QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, QUANTUM_WORKSPACE_OWNER_ROLE_ID, SUPPORTED_STORAGE_SKU_TIERS, SUPPORTED_STORAGE_KINDS, DEPLOYMENT_NAME_PREFIX +from ...operations.workspace import _merge_workspace_quotas +from ...commands import transform_workspace_quotas from ...vendored_sdks.azure_mgmt_quantum.models import Provider, TargetQuotaAllocations +from ...vendored_sdks.azure_quantum_python._client.operations._operations import ( + build_services_quotas_list_quota_usages_request, +) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) @@ -122,9 +127,12 @@ def test_workspace_create_destroy(self): # list quotas results = self.cmd('az quantum workspace quotas -o json').get_output_in_json() - assert len(results) > 0 - assert len(results[0]["dimension"]) > 0 - assert (results[0]["holds"]) >= 0.0 + assert isinstance(results, list) + for row in results: + self.assertEqual(set(row.keys()), {'providerId', 'scope', 'targetId', 'allocation', 'usage'}) + self.assertEqual(row['scope'], 'WorkspaceTarget') + self.assertEqual(set(row['allocation'].keys()), {'standardMinutesLifetime', 'highMinutesLifetime'}) + self.assertEqual(set(row['usage'].keys()), {'standardMinutesLifetime', 'highMinutesLifetime'}) # delete self.cmd(f'az quantum workspace delete -g {test_resource_group} -w {test_workspace_temp} -o json', checks=[ @@ -237,7 +245,6 @@ def test_workspace_v2_create_destroy(self): self.check("properties.provisioningState", "Deleting") ]) - @live_only() def test_workspace_keys(self): # initialize values @@ -653,6 +660,145 @@ class TestWorkspaceInfo(object): assert resource_id == "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyResourceGroup/providers/Microsoft.Quantum/Workspaces/MyWorkspace" +class QuantumWorkspaceQuotasTest(unittest.TestCase): + + def test_build_workspace_quotas_list_quota_usages_request(self): + request = build_services_quotas_list_quota_usages_request( + subscription_id='00000000-0000-0000-0000-000000000000', + resource_group_name='MyResourceGroup', + workspace_name='MyWorkspace', + provider_id='ionq', + ) + self.assertEqual(request.method, 'GET') + self.assertIn( + '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/MyResourceGroup/providers/Microsoft.Quantum/workspaces/MyWorkspace/quotaUsages', + request.url, + ) + self.assertIn('providerId=ionq', request.url) + self.assertIn('api-version=2026-01-15-preview', request.url) + + def test_merge_workspace_quotas_with_usage(self): + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[ + SimpleNamespace(provider_id='ionq', target_quotas=[ + SimpleNamespace(target_id='ionq.qpu', standard_minutes_lifetime=30, high_minutes_lifetime=15), + ]), + ])) + usages = [ + SimpleNamespace(provider_id='ionq', target_id='ionq.qpu', + usage=SimpleNamespace(standard_minutes_lifetime=5, high_minutes_lifetime=2)), + ] + + rows = _merge_workspace_quotas(workspace, usages) + + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(list(row.keys()), ['providerId', 'scope', 'targetId', 'allocation', 'usage']) + self.assertEqual(row['providerId'], 'ionq') + self.assertEqual(row['scope'], 'WorkspaceTarget') + self.assertEqual(row['targetId'], 'ionq.qpu') + self.assertEqual(row['allocation'], {'standardMinutesLifetime': 30, 'highMinutesLifetime': 15}) + self.assertEqual(row['usage'], {'standardMinutesLifetime': 5, 'highMinutesLifetime': 2}) + + def test_merge_workspace_quotas_without_usage(self): + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[ + SimpleNamespace(provider_id='ionq', target_quotas=[ + SimpleNamespace(target_id='ionq.qpu', standard_minutes_lifetime=30, high_minutes_lifetime=None), + ]), + ])) + + rows = _merge_workspace_quotas(workspace, []) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['allocation'], {'standardMinutesLifetime': 30, 'highMinutesLifetime': None}) + self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': None, 'highMinutesLifetime': None}) + + def test_merge_workspace_quotas_matches_on_provider_and_target(self): + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[ + SimpleNamespace(provider_id='ionq', target_quotas=[ + SimpleNamespace(target_id='shared.target', standard_minutes_lifetime=30, high_minutes_lifetime=15), + ]), + ])) + usages = [ + # Same target id but a different provider -> must not match. + SimpleNamespace(provider_id='quantinuum', target_id='shared.target', + usage=SimpleNamespace(standard_minutes_lifetime=9, high_minutes_lifetime=4)), + ] + + rows = _merge_workspace_quotas(workspace, usages) + + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['providerId'], 'ionq') + self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': None, 'highMinutesLifetime': None}) + + def test_merge_workspace_quotas_handles_missing_properties(self): + workspace = SimpleNamespace(location='eastus', properties=None) + self.assertEqual(_merge_workspace_quotas(workspace, []), []) + + def test_transform_workspace_quotas(self): + quotas = [ + { + 'providerId': 'ionq', + 'scope': 'WorkspaceTarget', + 'targetId': 'ionq.qpu', + 'allocation': {'standardMinutesLifetime': 100, 'highMinutesLifetime': 50}, + 'usage': {'standardMinutesLifetime': 40, 'highMinutesLifetime': 10}, + } + ] + + table = transform_workspace_quotas(quotas) + + self.assertEqual(len(table), 1) + row = table[0] + self.assertEqual(list(row.keys()), [ + 'Provider', 'Target', 'Std Allocated', 'Std Used', 'High Allocated', 'High Used' + ]) + self.assertEqual(row['Provider'], 'ionq') + self.assertEqual(row['Target'], 'ionq.qpu') + self.assertEqual(row['Std Allocated'], 100) + self.assertEqual(row['Std Used'], 40) + self.assertEqual(row['High Allocated'], 50) + self.assertEqual(row['High Used'], 10) + + def test_quotas_handler_queries_each_provider_and_merges(self): + info = SimpleNamespace(subscription='sub', resource_group='rg', name='ws', endpoint=None) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[ + SimpleNamespace(provider_id='ionq', target_quotas=[ + SimpleNamespace(target_id='ionq.qpu', standard_minutes_lifetime=30, high_minutes_lifetime=15), + ]), + SimpleNamespace(provider_id='quantinuum', target_quotas=[ + SimpleNamespace(target_id='quantinuum.qpu', standard_minutes_lifetime=100, high_minutes_lifetime=None), + ]), + ])) + + usage_by_provider = { + 'ionq': [SimpleNamespace(provider_id='ionq', target_id='ionq.qpu', + usage=SimpleNamespace(standard_minutes_lifetime=5, high_minutes_lifetime=2))], + 'quantinuum': [], + } + queried = [] + + def fake_list_quota_usages(subscription, resource_group, workspace_name, provider_id): + queried.append(provider_id) + return usage_by_provider[provider_id] + + client = SimpleNamespace(list_quota_usages=fake_list_quota_usages) + + from ...operations import workspace as workspace_ops + with patch.object(workspace_ops, 'WorkspaceInfo', return_value=info), \ + patch.object(workspace_ops, 'cf_workspaces', return_value=SimpleNamespace(get=lambda rg, ws: workspace)), \ + patch.object(workspace_ops, 'base_url_v2', return_value='https://eastus-v2.quantum.azure.com/'), \ + patch.object(workspace_ops, 'cf_quotas', return_value=client): + cmd = SimpleNamespace(cli_ctx=object()) + rows = workspace_ops.quotas(cmd, 'rg', 'ws') + + self.assertEqual(set(queried), {'ionq', 'quantinuum'}) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]['providerId'], 'ionq') + self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': 5, 'highMinutesLifetime': 2}) + self.assertEqual(rows[1]['providerId'], 'quantinuum') + self.assertEqual(rows[1]['usage'], {'standardMinutesLifetime': None, 'highMinutesLifetime': None}) + + class QuantumWorkspaceUserListTest(unittest.TestCase): def test_list_users_scopes_to_workspace(self): info = SimpleNamespace(subscription="sub", resource_group="rg", name="ws", endpoint=None) diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py index 8340a21a72f..ba9ba2a3c95 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py @@ -43,6 +43,7 @@ build_services_jobs_update_request, build_services_providers_list_request, build_services_quotas_list_request, + build_services_quotas_list_quota_usages_request, build_services_sessions_close_request, build_services_sessions_get_request, build_services_sessions_jobs_list_request, @@ -1204,6 +1205,72 @@ async def get_next(next_link=None): return AsyncItemPaged(get_next, extract_data) + @distributed_trace_async + async def list_quota_usages( + self, subscription_id: str, resource_group_name: str, workspace_name: str, provider_id: str, **kwargs: Any + ) -> "list[_models.QuotaUsage]": + """List quota usages for a provider in the given workspace. + + :param subscription_id: The Azure subscription ID. Required. + :type subscription_id: str + :param resource_group_name: Name of the Azure resource group. Required. + :type resource_group_name: str + :param workspace_name: Name of the Azure Quantum workspace. Required. + :type workspace_name: str + :param provider_id: The provider whose quota usages are requested. Required. + :type provider_id: str + :return: list of QuotaUsage + :rtype: list[~azure.quantum.models.QuotaUsage] + :raises ~azure.core.exceptions.HttpResponseError: + """ + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) + + error_map: MutableMapping = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _request = build_services_quotas_list_quota_usages_request( + subscription_id=subscription_id, + resource_group_name=resource_group_name, + workspace_name=workspace_name, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + _stream = False + pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + response = pipeline_response.http_response + + if response.status_code not in [200]: + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) + + deserialized = response.json() + # The service may return either a bare JSON array or a paged envelope ({"value": [...]}). + if isinstance(deserialized, dict): + deserialized = deserialized.get("value", []) + list_of_elem = _deserialize(list[_models.QuotaUsage], deserialized) + if cls: + return cls(list_of_elem) # type: ignore + return list_of_elem + class ServicesSuiteOffersOperations: """ diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py index 99e28e36f7e..1acb4856e88 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py @@ -332,6 +332,35 @@ def build_services_quotas_list_request( return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) +def build_services_quotas_list_quota_usages_request( # pylint: disable=name-too-long + subscription_id: str, resource_group_name: str, workspace_name: str, provider_id: str, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + api_version: str = kwargs.pop("api_version", _params.pop("api-version", "2026-01-15-preview")) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/quotaUsages" + path_format_arguments = { + "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), + "resourceGroupName": _SERIALIZER.url("resource_group_name", resource_group_name, "str"), + "workspaceName": _SERIALIZER.url("workspace_name", workspace_name, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct parameters + _params["providerId"] = _SERIALIZER.query("provider_id", provider_id, "str") + _params["api-version"] = _SERIALIZER.query("api_version", api_version, "str") + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) + + def build_services_suite_offers_list_quota_usages_request( # pylint: disable=name-too-long subscription_id: str, provider_id: str, **kwargs: Any ) -> HttpRequest: @@ -1741,6 +1770,72 @@ def get_next(next_link=None): return ItemPaged(get_next, extract_data) + @distributed_trace + def list_quota_usages( + self, subscription_id: str, resource_group_name: str, workspace_name: str, provider_id: str, **kwargs: Any + ) -> "list[_models.QuotaUsage]": + """List quota usages for a provider in the given workspace. + + :param subscription_id: The Azure subscription ID. Required. + :type subscription_id: str + :param resource_group_name: Name of the Azure resource group. Required. + :type resource_group_name: str + :param workspace_name: Name of the Azure Quantum workspace. Required. + :type workspace_name: str + :param provider_id: The provider whose quota usages are requested. Required. + :type provider_id: str + :return: list of QuotaUsage + :rtype: list[~azure.quantum.models.QuotaUsage] + :raises ~azure.core.exceptions.HttpResponseError: + """ + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) + + error_map: MutableMapping = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _request = build_services_quotas_list_quota_usages_request( + subscription_id=subscription_id, + resource_group_name=resource_group_name, + workspace_name=workspace_name, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + _stream = False + pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + response = pipeline_response.http_response + + if response.status_code not in [200]: + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) + + deserialized = response.json() + # The service may return either a bare JSON array or a paged envelope ({"value": [...]}). + if isinstance(deserialized, dict): + deserialized = deserialized.get("value", []) + list_of_elem = _deserialize(list[_models.QuotaUsage], deserialized) + if cls: + return cls(list_of_elem) # type: ignore + return list_of_elem + class ServicesSuiteOffersOperations: """ From febfd9c87b338d92e791d64d01f3fce451e6e8ad Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Wed, 2 Sep 2026 13:07:02 -0700 Subject: [PATCH 11/24] [Quantum] Address review: use 'Provider ID' column in workspace quotas table --- src/quantum/azext_quantum/commands.py | 2 +- .../azext_quantum/tests/latest/test_quantum_workspace.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index d121a891d93..7d8e31f5685 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -133,7 +133,7 @@ def cell(source, key): return '' if value is None else value return OrderedDict([ - ('Provider', quota.get('providerId', '')), + ('Provider ID', quota.get('providerId', '')), ('Target', quota.get('targetId', '')), ('Std Allocated', cell(allocation, 'standardMinutesLifetime')), ('Std Used', cell(usage, 'standardMinutesLifetime')), diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 578586ecf7f..1674328b063 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -750,9 +750,9 @@ def test_transform_workspace_quotas(self): self.assertEqual(len(table), 1) row = table[0] self.assertEqual(list(row.keys()), [ - 'Provider', 'Target', 'Std Allocated', 'Std Used', 'High Allocated', 'High Used' + 'Provider ID', 'Target', 'Std Allocated', 'Std Used', 'High Allocated', 'High Used' ]) - self.assertEqual(row['Provider'], 'ionq') + self.assertEqual(row['Provider ID'], 'ionq') self.assertEqual(row['Target'], 'ionq.qpu') self.assertEqual(row['Std Allocated'], 100) self.assertEqual(row['Std Used'], 40) From 1f6bf17525e62ef43ca8d53a7672a9fe83b2c112 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Wed, 2 Sep 2026 13:14:34 -0700 Subject: [PATCH 12/24] [Quantum] Address review: lift quota scope literal into a named constant --- src/quantum/azext_quantum/operations/suite_offers.py | 5 ++++- src/quantum/azext_quantum/operations/workspace.py | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py index 406b63b1dc9..d1c948a364f 100644 --- a/src/quantum/azext_quantum/operations/suite_offers.py +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -13,6 +13,9 @@ from .._client_factory import cf_suite_offers, cf_suite_offers_data_plane, base_url_v2 +# Suite offer quota allocations are always reported at the per-target scope. +_SUITE_OFFER_QUOTA_SCOPE = "SubscriptionTarget" + def list_suite_offers(cmd): """ @@ -118,7 +121,7 @@ def _merge_suite_offer_quotas(offer, usages, provider_id): row = OrderedDict() row["providerId"] = provider_id - row["scope"] = "SubscriptionTarget" + row["scope"] = _SUITE_OFFER_QUOTA_SCOPE row["targetId"] = target_quota.target_id row["allocation"] = _minutes( target_quota.standard_minutes_lifetime, diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 77d9f72406e..b011cb7204a 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -478,6 +478,10 @@ def quotas(cmd, resource_group_name, workspace_name): return _merge_workspace_quotas(workspace, usages) +# Workspace quota allocations are always reported at the per-target scope. +_WORKSPACE_QUOTA_SCOPE = "WorkspaceTarget" + + def _quota_minutes(standard, high): """Build a {standardMinutesLifetime, highMinutesLifetime} block.""" return OrderedDict([ @@ -509,7 +513,7 @@ def _merge_workspace_quotas(workspace, usages): row = OrderedDict() row["providerId"] = provider.provider_id - row["scope"] = "WorkspaceTarget" + row["scope"] = _WORKSPACE_QUOTA_SCOPE row["targetId"] = target_quota.target_id row["allocation"] = _quota_minutes( target_quota.standard_minutes_lifetime, From d89e4d7b4e68d21fdc77c1aaabae543300bcf197 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Wed, 2 Sep 2026 13:35:50 -0700 Subject: [PATCH 13/24] [Quantum] Address review: show quota allocation/usage in hours in table view --- src/quantum/azext_quantum/commands.py | 27 ++++++++++--------- .../tests/latest/test_quantum_suite_offers.py | 10 +++---- .../tests/latest/test_quantum_workspace.py | 10 +++---- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 7d8e31f5685..000e7538dec 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -103,21 +103,25 @@ def one(offer): return [one(offer) for offer in suite_offers] +def _quota_hours(minutes): + """Convert lifetime quota minutes to hours (2 dp), matching the Quantum studio UI.""" + return '' if minutes is None else round(minutes / 60, 2) + + def transform_suite_offer_quotas(quotas): def one(quota): allocation = quota.get('allocation') or {} usage = quota.get('usage') or {} def cell(source, key): - value = source.get(key) - return '' if value is None else value + return _quota_hours(source.get(key)) return OrderedDict([ ('Target', quota.get('targetId', '')), - ('Std Allocated', cell(allocation, 'standardMinutesLifetime')), - ('Std Used', cell(usage, 'standardMinutesLifetime')), - ('High Allocated', cell(allocation, 'highMinutesLifetime')), - ('High Used', cell(usage, 'highMinutesLifetime')) + ('Std Allocated (hrs)', cell(allocation, 'standardMinutesLifetime')), + ('Std Used (hrs)', cell(usage, 'standardMinutesLifetime')), + ('High Allocated (hrs)', cell(allocation, 'highMinutesLifetime')), + ('High Used (hrs)', cell(usage, 'highMinutesLifetime')) ]) return [one(quota) for quota in quotas] @@ -129,16 +133,15 @@ def one(quota): usage = quota.get('usage') or {} def cell(source, key): - value = source.get(key) - return '' if value is None else value + return _quota_hours(source.get(key)) return OrderedDict([ ('Provider ID', quota.get('providerId', '')), ('Target', quota.get('targetId', '')), - ('Std Allocated', cell(allocation, 'standardMinutesLifetime')), - ('Std Used', cell(usage, 'standardMinutesLifetime')), - ('High Allocated', cell(allocation, 'highMinutesLifetime')), - ('High Used', cell(usage, 'highMinutesLifetime')) + ('Std Allocated (hrs)', cell(allocation, 'standardMinutesLifetime')), + ('Std Used (hrs)', cell(usage, 'standardMinutesLifetime')), + ('High Allocated (hrs)', cell(allocation, 'highMinutesLifetime')), + ('High Used (hrs)', cell(usage, 'highMinutesLifetime')) ]) return [one(quota) for quota in quotas] diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index c4404ee5226..7097acb5552 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -88,13 +88,13 @@ def test_transform_suite_offer_quotas(self): self.assertEqual(len(table), 1) row = table[0] self.assertEqual(list(row.keys()), [ - 'Target', 'Std Allocated', 'Std Used', 'High Allocated', 'High Used' + 'Target', 'Std Allocated (hrs)', 'Std Used (hrs)', 'High Allocated (hrs)', 'High Used (hrs)' ]) self.assertEqual(row['Target'], 'ionq.qpu') - self.assertEqual(row['Std Allocated'], 100) - self.assertEqual(row['Std Used'], 40) - self.assertEqual(row['High Allocated'], 50) - self.assertEqual(row['High Used'], 10) + self.assertEqual(row['Std Allocated (hrs)'], 1.67) + self.assertEqual(row['Std Used (hrs)'], 0.67) + self.assertEqual(row['High Allocated (hrs)'], 0.83) + self.assertEqual(row['High Used (hrs)'], 0.17) def test_base_url_v2(self): self.assertEqual(base_url_v2('East US'), 'https://eastus-v2.quantum.azure.com/') diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 1674328b063..8da6112366e 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -750,14 +750,14 @@ def test_transform_workspace_quotas(self): self.assertEqual(len(table), 1) row = table[0] self.assertEqual(list(row.keys()), [ - 'Provider ID', 'Target', 'Std Allocated', 'Std Used', 'High Allocated', 'High Used' + 'Provider ID', 'Target', 'Std Allocated (hrs)', 'Std Used (hrs)', 'High Allocated (hrs)', 'High Used (hrs)' ]) self.assertEqual(row['Provider ID'], 'ionq') self.assertEqual(row['Target'], 'ionq.qpu') - self.assertEqual(row['Std Allocated'], 100) - self.assertEqual(row['Std Used'], 40) - self.assertEqual(row['High Allocated'], 50) - self.assertEqual(row['High Used'], 10) + self.assertEqual(row['Std Allocated (hrs)'], 1.67) + self.assertEqual(row['Std Used (hrs)'], 0.67) + self.assertEqual(row['High Allocated (hrs)'], 0.83) + self.assertEqual(row['High Used (hrs)'], 0.17) def test_quotas_handler_queries_each_provider_and_merges(self): info = SimpleNamespace(subscription='sub', resource_group='rg', name='ws', endpoint=None) From 37148ab0056ecb37251494da4f2047e6c321e9da Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Fri, 4 Sep 2026 11:53:31 -0700 Subject: [PATCH 14/24] [Quantum] Preserve workspace quota response compatibility --- src/quantum/HISTORY.rst | 2 +- src/quantum/azext_quantum/_help.py | 10 +- src/quantum/azext_quantum/commands.py | 22 ++- .../azext_quantum/operations/workspace.py | 128 +++++++------ .../tests/latest/test_quantum_suite_offers.py | 14 +- .../tests/latest/test_quantum_workspace.py | 177 ++++++++++++------ 6 files changed, 226 insertions(+), 127 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index ac5294d5c8c..8423c21fbb1 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -6,7 +6,7 @@ Release History 1.0.0b27 ++++++++++++++ * Added the ``az quantum suite-offer target list`` command to list the targets and their status available through a suite offer provider account, without requiring a workspace. -* Updated the ``az quantum workspace quotas`` command to return v2 workspace target quota allocations merged with their consumed usages. +* Updated the ``az quantum workspace quotas`` command to include v2 target quota allocations and usages while preserving the existing response format for v1 providers. 1.0.0b26 ++++++++++++++ diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 5f6062522a6..0f1937ed38a 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -406,13 +406,13 @@ helps['quantum workspace quotas'] = """ type: command - short-summary: List the v2 target quota allocations, merged with their consumed usages, for an Azure Quantum workspace. + short-summary: List quota allocations and consumed usages for an Azure Quantum workspace. long-summary: | - Returns the target quota allocations (limits) configured on the workspace together with the - consumed usages reported by the data plane. Each entry reports the allocated and used standard - and high priority minutes over the lifetime of the workspace. + Preserves the existing quota dimension response for v1 providers. For v2 providers, returns + separate StandardMinutesLifetime and HighMinutesLifetime rows for each target, with targetId, + limit, and utilization reported in minutes. Missing allocation or usage values are returned as 0. examples: - - name: View the quota usages for the given (or current) workspace. If a default workspace has been set, the -g and -w parameters are not required. + - name: View quota allocations and usages for the given (or current) workspace. If a default workspace has been set, the -g and -w parameters are not required. text: |- az quantum workspace quotas -g MyResourceGroup -w MyWorkspace -o table """ diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 000e7538dec..d1f69699317 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -105,7 +105,7 @@ def one(offer): def _quota_hours(minutes): """Convert lifetime quota minutes to hours (2 dp), matching the Quantum studio UI.""" - return '' if minutes is None else round(minutes / 60, 2) + return 0 if minutes is None else round(minutes / 60, 2) def transform_suite_offer_quotas(quotas): @@ -129,19 +129,23 @@ def cell(source, key): def transform_workspace_quotas(quotas): def one(quota): - allocation = quota.get('allocation') or {} - usage = quota.get('usage') or {} + is_target_quota = quota.get('targetId') is not None - def cell(source, key): - return _quota_hours(source.get(key)) + def value(key): + result = quota.get(key, 0) + if is_target_quota and isinstance(result, float): + return round(result, 2) + return result return OrderedDict([ + ('Dimension', quota.get('dimension', '')), ('Provider ID', quota.get('providerId', '')), + ('Scope', quota.get('scope', '')), ('Target', quota.get('targetId', '')), - ('Std Allocated (hrs)', cell(allocation, 'standardMinutesLifetime')), - ('Std Used (hrs)', cell(usage, 'standardMinutesLifetime')), - ('High Allocated (hrs)', cell(allocation, 'highMinutesLifetime')), - ('High Used (hrs)', cell(usage, 'highMinutesLifetime')) + ('Limit', value('limit')), + ('Utilization', value('utilization')), + ('Holds', value('holds')), + ('Period', quota.get('period', '')), ]) return [one(quota) for quota in quotas] diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index b011cb7204a..4dc6f58d54b 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -11,7 +11,6 @@ import time from builtins import set as builtin_set -from collections import OrderedDict from azure.cli.command_modules.storage.operations.account import list_storage_accounts @@ -24,7 +23,8 @@ MutuallyExclusiveArgumentError) from azure.core.exceptions import ResourceNotFoundError as AzureResourceNotFoundError -from .._client_factory import cf_workspaces, cf_quotas, cf_offerings, _get_data_credentials, base_url_v2 +from .._client_factory import cf_workspaces, cf_quotas, cf_offerings, _get_data_credentials, base_url, base_url_v2 +from .._list_helper import repack_response_json from ..vendored_sdks.azure_mgmt_quantum.models import QuantumWorkspace from ..vendored_sdks.azure_mgmt_quantum.models import ManagedServiceIdentity from ..vendored_sdks.azure_mgmt_quantum.models import Provider, ApiKeys, WorkspaceResourceProperties, KeyType, TargetQuotaAllocations @@ -445,59 +445,62 @@ def get(cmd, resource_group_name=None, workspace_name=None): def quotas(cmd, resource_group_name, workspace_name): """ - List the v2 target quota allocations for the given (or current) Azure Quantum workspace, - merged with their consumed usages. + List quota allocations and usages for the given (or current) Azure Quantum workspace. """ info = WorkspaceInfo(cmd, resource_group_name, workspace_name) - # 1. Control-plane: fetch the workspace for its target quota allocations and region. workspace = cf_workspaces(cmd.cli_ctx).get(info.resource_group, info.name) - properties = workspace.properties providers = properties.providers if properties is not None else None - # 2. Data-plane (v2): fetch the consumed quota usages per provider in the workspace. - # The workspace quotaUsages endpoint requires a providerId, so query each provider. - endpoint = base_url_v2(workspace.location) - client = cf_quotas(cmd.cli_ctx, info.subscription, info.resource_group, info.name, endpoint) + legacy_client = cf_quotas( + cmd.cli_ctx, info.subscription, info.resource_group, info.name, base_url(workspace.location)) + legacy_quotas = repack_response_json( + legacy_client.list(info.subscription, info.resource_group, info.name)) usages = [] - for provider in providers or []: - # Only providers with target quota allocations produce rows, so skip the rest. - if not provider.target_quotas: - continue - try: - provider_usages = client.list_quota_usages( - info.subscription, info.resource_group, info.name, provider.provider_id) - except AzureResourceNotFoundError: - # No usages have been recorded yet for this provider; report allocations only. - provider_usages = None - usages.extend(provider_usages or []) - - # 3. Merge allocations (limits) with usages (consumed). - return _merge_workspace_quotas(workspace, usages) - - -# Workspace quota allocations are always reported at the per-target scope. -_WORKSPACE_QUOTA_SCOPE = "WorkspaceTarget" - - -def _quota_minutes(standard, high): - """Build a {standardMinutesLifetime, highMinutesLifetime} block.""" - return OrderedDict([ - ("standardMinutesLifetime", standard), - ("highMinutesLifetime", high), - ]) + workspace_kind = getattr(properties, 'workspace_kind', None) if properties is not None else None + if str(_enum_to_value(workspace_kind)).upper() == 'V2': + v2_client = cf_quotas( + cmd.cli_ctx, info.subscription, info.resource_group, info.name, base_url_v2(workspace.location)) + for provider in providers or []: + try: + provider_usages = v2_client.list_quota_usages( + info.subscription, info.resource_group, info.name, provider.provider_id) + except AzureResourceNotFoundError: + provider_usages = None + usages.extend(provider_usages or []) + + return _merge_workspace_quotas(workspace, usages, legacy_quotas) + + +_WORKSPACE_QUOTA_SCOPE = "Workspace" +_WORKSPACE_QUOTA_PERIOD = "None" +_TARGET_QUOTA_DIMENSIONS = ( + ("StandardMinutesLifetime", "standard_minutes_lifetime"), + ("HighMinutesLifetime", "high_minutes_lifetime"), +) + + +def _target_quota_row(provider_id, target_id, dimension, allocation, usage): + return { + "dimension": dimension, + "providerId": provider_id, + "scope": _WORKSPACE_QUOTA_SCOPE, + "limit": allocation if allocation is not None else 0, + "utilization": usage if usage is not None else 0, + "holds": 0.0, + "period": _WORKSPACE_QUOTA_PERIOD, + "targetId": target_id, + } -def _merge_workspace_quotas(workspace, usages): +def _merge_workspace_quotas(workspace, usages, legacy_quotas=None): """ - Build one row per provider target quota allocation, attaching its matching data-plane usage. - Workspace quotas are reported at the WorkspaceTarget scope. + Preserve legacy quota rows and append one flat row per target and priority for v2 quotas. """ - # Data-plane usages keyed by (provider id, target id). usage_by_key = { - (usage.provider_id, usage.target_id): usage + ((usage.provider_id or '').lower(), usage.target_id.lower()): usage for usage in (usages or []) if usage.target_id is not None } @@ -505,25 +508,34 @@ def _merge_workspace_quotas(workspace, usages): properties = workspace.properties providers = properties.providers if properties is not None else None - rows = [] + rows = [row for row in (legacy_quotas or [])] for provider in sorted(providers or [], key=lambda p: p.provider_id or ""): - for target_quota in sorted(provider.target_quotas or [], key=lambda q: q.target_id or ""): - usage = usage_by_key.get((provider.provider_id, target_quota.target_id)) + allocations_by_target = { + quota.target_id.lower(): quota + for quota in (provider.target_quotas or []) + if quota.target_id is not None + } + usage_targets = { + target_id: usage + for (provider_id, target_id), usage in usage_by_key.items() + if provider_id == (provider.provider_id or '').lower() + } + target_ids = sorted(builtin_set(allocations_by_target) | builtin_set(usage_targets)) + + for target_id in target_ids: + target_quota = allocations_by_target.get(target_id) + usage = usage_targets.get(target_id) usage_values = usage.usage if usage is not None else None - - row = OrderedDict() - row["providerId"] = provider.provider_id - row["scope"] = _WORKSPACE_QUOTA_SCOPE - row["targetId"] = target_quota.target_id - row["allocation"] = _quota_minutes( - target_quota.standard_minutes_lifetime, - target_quota.high_minutes_lifetime, - ) - row["usage"] = _quota_minutes( - usage_values.standard_minutes_lifetime if usage_values is not None else None, - usage_values.high_minutes_lifetime if usage_values is not None else None, - ) - rows.append(row) + display_target_id = target_quota.target_id if target_quota is not None else usage.target_id + + for dimension, attribute in _TARGET_QUOTA_DIMENSIONS: + rows.append(_target_quota_row( + provider.provider_id, + display_target_id, + dimension, + getattr(target_quota, attribute, None), + getattr(usage_values, attribute, None), + )) return rows diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index 7097acb5552..e394edafc28 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -80,12 +80,19 @@ def test_transform_suite_offer_quotas(self): 'targetId': 'ionq.qpu', 'allocation': {'standardMinutesLifetime': 100, 'highMinutesLifetime': 50}, 'usage': {'standardMinutesLifetime': 40, 'highMinutesLifetime': 10}, + }, + { + 'providerId': 'ionq', + 'scope': 'SubscriptionTarget', + 'targetId': 'ionq.simulator', + 'allocation': {'standardMinutesLifetime': None, 'highMinutesLifetime': None}, + 'usage': {'standardMinutesLifetime': None, 'highMinutesLifetime': None}, } ] table = transform_suite_offer_quotas(quotas) - self.assertEqual(len(table), 1) + self.assertEqual(len(table), 2) row = table[0] self.assertEqual(list(row.keys()), [ 'Target', 'Std Allocated (hrs)', 'Std Used (hrs)', 'High Allocated (hrs)', 'High Used (hrs)' @@ -95,6 +102,11 @@ def test_transform_suite_offer_quotas(self): self.assertEqual(row['Std Used (hrs)'], 0.67) self.assertEqual(row['High Allocated (hrs)'], 0.83) self.assertEqual(row['High Used (hrs)'], 0.17) + missing_row = table[1] + self.assertEqual(missing_row['Std Allocated (hrs)'], 0) + self.assertEqual(missing_row['Std Used (hrs)'], 0) + self.assertEqual(missing_row['High Allocated (hrs)'], 0) + self.assertEqual(missing_row['High Used (hrs)'], 0) def test_base_url_v2(self): self.assertEqual(base_url_v2('East US'), 'https://eastus-v2.quantum.azure.com/') diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 8da6112366e..2573a309abb 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -127,12 +127,9 @@ def test_workspace_create_destroy(self): # list quotas results = self.cmd('az quantum workspace quotas -o json').get_output_in_json() - assert isinstance(results, list) - for row in results: - self.assertEqual(set(row.keys()), {'providerId', 'scope', 'targetId', 'allocation', 'usage'}) - self.assertEqual(row['scope'], 'WorkspaceTarget') - self.assertEqual(set(row['allocation'].keys()), {'standardMinutesLifetime', 'highMinutesLifetime'}) - self.assertEqual(set(row['usage'].keys()), {'standardMinutesLifetime', 'highMinutesLifetime'}) + assert len(results) > 0 + assert len(results[0]["dimension"]) > 0 + assert results[0]["holds"] >= 0.0 # delete self.cmd(f'az quantum workspace delete -g {test_resource_group} -w {test_workspace_temp} -o json', checks=[ @@ -683,21 +680,39 @@ def test_merge_workspace_quotas_with_usage(self): SimpleNamespace(target_id='ionq.qpu', standard_minutes_lifetime=30, high_minutes_lifetime=15), ]), ])) + legacy_quotas = [{ + 'dimension': 'emulator_hours', 'providerId': 'pasqal', 'scope': 'Subscription', + 'limit': 5.0, 'utilization': 1.0, 'holds': 0.0, 'period': 'Monthly' + }] usages = [ SimpleNamespace(provider_id='ionq', target_id='ionq.qpu', usage=SimpleNamespace(standard_minutes_lifetime=5, high_minutes_lifetime=2)), ] - rows = _merge_workspace_quotas(workspace, usages) - - self.assertEqual(len(rows), 1) - row = rows[0] - self.assertEqual(list(row.keys()), ['providerId', 'scope', 'targetId', 'allocation', 'usage']) - self.assertEqual(row['providerId'], 'ionq') - self.assertEqual(row['scope'], 'WorkspaceTarget') - self.assertEqual(row['targetId'], 'ionq.qpu') - self.assertEqual(row['allocation'], {'standardMinutesLifetime': 30, 'highMinutesLifetime': 15}) - self.assertEqual(row['usage'], {'standardMinutesLifetime': 5, 'highMinutesLifetime': 2}) + rows = _merge_workspace_quotas(workspace, usages, legacy_quotas) + + self.assertEqual(len(rows), 3) + self.assertEqual(rows[0], legacy_quotas[0]) + self.assertEqual(rows[1], { + 'dimension': 'StandardMinutesLifetime', + 'providerId': 'ionq', + 'scope': 'Workspace', + 'limit': 30, + 'utilization': 5, + 'holds': 0.0, + 'period': 'None', + 'targetId': 'ionq.qpu', + }) + self.assertEqual(rows[2], { + 'dimension': 'HighMinutesLifetime', + 'providerId': 'ionq', + 'scope': 'Workspace', + 'limit': 15, + 'utilization': 2, + 'holds': 0.0, + 'period': 'None', + 'targetId': 'ionq.qpu', + }) def test_merge_workspace_quotas_without_usage(self): workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[ @@ -708,9 +723,13 @@ def test_merge_workspace_quotas_without_usage(self): rows = _merge_workspace_quotas(workspace, []) - self.assertEqual(len(rows), 1) - self.assertEqual(rows[0]['allocation'], {'standardMinutesLifetime': 30, 'highMinutesLifetime': None}) - self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': None, 'highMinutesLifetime': None}) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]['dimension'], 'StandardMinutesLifetime') + self.assertEqual(rows[0]['limit'], 30) + self.assertEqual(rows[0]['utilization'], 0) + self.assertEqual(rows[1]['dimension'], 'HighMinutesLifetime') + self.assertEqual(rows[1]['limit'], 0) + self.assertEqual(rows[1]['utilization'], 0) def test_merge_workspace_quotas_matches_on_provider_and_target(self): workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[ @@ -726,77 +745,129 @@ def test_merge_workspace_quotas_matches_on_provider_and_target(self): rows = _merge_workspace_quotas(workspace, usages) - self.assertEqual(len(rows), 1) + self.assertEqual(len(rows), 2) self.assertEqual(rows[0]['providerId'], 'ionq') - self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': None, 'highMinutesLifetime': None}) + self.assertEqual(rows[0]['utilization'], 0) + + def test_merge_workspace_quotas_includes_usage_without_allocation(self): + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[ + SimpleNamespace(provider_id='ionq', target_quotas=[]), + ])) + usages = [ + SimpleNamespace(provider_id='IONQ', target_id='ionq.retired-target', + usage=SimpleNamespace(standard_minutes_lifetime=9, high_minutes_lifetime=None)), + ] + + rows = _merge_workspace_quotas(workspace, usages) + + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]['targetId'], 'ionq.retired-target') + self.assertEqual(rows[0]['limit'], 0) + self.assertEqual(rows[0]['utilization'], 9) + self.assertEqual(rows[1]['limit'], 0) + self.assertEqual(rows[1]['utilization'], 0) def test_merge_workspace_quotas_handles_missing_properties(self): workspace = SimpleNamespace(location='eastus', properties=None) - self.assertEqual(_merge_workspace_quotas(workspace, []), []) + legacy_quotas = [{'dimension': 'legacy'}] + self.assertEqual(_merge_workspace_quotas(workspace, [], legacy_quotas), legacy_quotas) - def test_transform_workspace_quotas(self): + def test_transform_workspace_quotas_preserves_mixed_dimensions(self): quotas = [ { - 'providerId': 'ionq', - 'scope': 'WorkspaceTarget', - 'targetId': 'ionq.qpu', - 'allocation': {'standardMinutesLifetime': 100, 'highMinutesLifetime': 50}, - 'usage': {'standardMinutesLifetime': 40, 'highMinutesLifetime': 10}, - } + 'dimension': 'emulator_hours', 'providerId': 'pasqal', 'scope': 'Subscription', + 'limit': 5.0, 'utilization': 1.0, 'holds': 0.0, 'period': 'Monthly' + }, + { + 'dimension': 'StandardMinutesLifetime', 'providerId': 'ionq', 'scope': 'Workspace', + 'limit': 1200, 'utilization': 27.000000000000007, 'holds': 0.0, 'period': 'None', + 'targetId': 'ionq.qpu' + }, ] table = transform_workspace_quotas(quotas) - self.assertEqual(len(table), 1) - row = table[0] - self.assertEqual(list(row.keys()), [ - 'Provider ID', 'Target', 'Std Allocated (hrs)', 'Std Used (hrs)', 'High Allocated (hrs)', 'High Used (hrs)' + self.assertEqual(list(table[0].keys()), [ + 'Dimension', 'Provider ID', 'Scope', 'Target', 'Limit', 'Utilization', 'Holds', 'Period' ]) - self.assertEqual(row['Provider ID'], 'ionq') - self.assertEqual(row['Target'], 'ionq.qpu') - self.assertEqual(row['Std Allocated (hrs)'], 1.67) - self.assertEqual(row['Std Used (hrs)'], 0.67) - self.assertEqual(row['High Allocated (hrs)'], 0.83) - self.assertEqual(row['High Used (hrs)'], 0.17) + self.assertEqual(table[0]['Target'], '') + self.assertEqual(table[0]['Limit'], 5.0) + self.assertEqual(table[0]['Utilization'], 1.0) + self.assertEqual(table[1]['Target'], 'ionq.qpu') + self.assertEqual(table[1]['Limit'], 1200) + self.assertEqual(table[1]['Utilization'], 27.0) def test_quotas_handler_queries_each_provider_and_merges(self): info = SimpleNamespace(subscription='sub', resource_group='rg', name='ws', endpoint=None) - workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[ + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(workspace_kind='V2', providers=[ SimpleNamespace(provider_id='ionq', target_quotas=[ SimpleNamespace(target_id='ionq.qpu', standard_minutes_lifetime=30, high_minutes_lifetime=15), ]), - SimpleNamespace(provider_id='quantinuum', target_quotas=[ - SimpleNamespace(target_id='quantinuum.qpu', standard_minutes_lifetime=100, high_minutes_lifetime=None), - ]), + SimpleNamespace(provider_id='pasqal', target_quotas=None), ])) + legacy_row = { + 'dimension': 'emulator_hours', 'providerId': 'pasqal', 'scope': 'Subscription', + 'limit': 5.0, 'utilization': 1.0, 'holds': 0.0, 'period': 'Monthly' + } usage_by_provider = { 'ionq': [SimpleNamespace(provider_id='ionq', target_id='ionq.qpu', usage=SimpleNamespace(standard_minutes_lifetime=5, high_minutes_lifetime=2))], - 'quantinuum': [], + 'pasqal': [], } queried = [] - def fake_list_quota_usages(subscription, resource_group, workspace_name, provider_id): + def fake_list_quota_usages(*args): + provider_id = args[-1] queried.append(provider_id) return usage_by_provider[provider_id] - client = SimpleNamespace(list_quota_usages=fake_list_quota_usages) + legacy_client = SimpleNamespace(list=lambda *_: [legacy_row]) + v2_client = SimpleNamespace(list_quota_usages=fake_list_quota_usages) + + def fake_client_factory(*args): + endpoint = args[-1] + return legacy_client if endpoint == 'https://eastus.quantum.azure.com/' else v2_client from ...operations import workspace as workspace_ops with patch.object(workspace_ops, 'WorkspaceInfo', return_value=info), \ patch.object(workspace_ops, 'cf_workspaces', return_value=SimpleNamespace(get=lambda rg, ws: workspace)), \ + patch.object(workspace_ops, 'base_url', return_value='https://eastus.quantum.azure.com/'), \ patch.object(workspace_ops, 'base_url_v2', return_value='https://eastus-v2.quantum.azure.com/'), \ - patch.object(workspace_ops, 'cf_quotas', return_value=client): + patch.object(workspace_ops, 'cf_quotas', side_effect=fake_client_factory) as client_factory: cmd = SimpleNamespace(cli_ctx=object()) rows = workspace_ops.quotas(cmd, 'rg', 'ws') - self.assertEqual(set(queried), {'ionq', 'quantinuum'}) - self.assertEqual(len(rows), 2) - self.assertEqual(rows[0]['providerId'], 'ionq') - self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': 5, 'highMinutesLifetime': 2}) - self.assertEqual(rows[1]['providerId'], 'quantinuum') - self.assertEqual(rows[1]['usage'], {'standardMinutesLifetime': None, 'highMinutesLifetime': None}) + self.assertEqual(set(queried), {'ionq', 'pasqal'}) + self.assertEqual(client_factory.call_count, 2) + self.assertEqual(len(rows), 3) + self.assertEqual(rows[0], legacy_row) + self.assertEqual(rows[1]['dimension'], 'StandardMinutesLifetime') + self.assertEqual(rows[1]['limit'], 30) + self.assertEqual(rows[1]['utilization'], 5) + self.assertEqual(rows[2]['dimension'], 'HighMinutesLifetime') + self.assertEqual(rows[2]['limit'], 15) + self.assertEqual(rows[2]['utilization'], 2) + + def test_quotas_handler_keeps_v1_behavior_without_v2_usage_calls(self): + info = SimpleNamespace(subscription='sub', resource_group='rg', name='ws', endpoint=None) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace( + workspace_kind='V1', providers=[SimpleNamespace(provider_id='pasqal', target_quotas=None)])) + legacy_row = { + 'dimension': 'emulator_hours', 'providerId': 'pasqal', 'scope': 'Subscription', + 'limit': 5.0, 'utilization': 1.0, 'holds': 0.0, 'period': 'Monthly' + } + legacy_client = SimpleNamespace(list=lambda subscription, resource_group, workspace_name: [legacy_row]) + + from ...operations import workspace as workspace_ops + with patch.object(workspace_ops, 'WorkspaceInfo', return_value=info), \ + patch.object(workspace_ops, 'cf_workspaces', return_value=SimpleNamespace(get=lambda rg, ws: workspace)), \ + patch.object(workspace_ops, 'base_url', return_value='https://eastus.quantum.azure.com/'), \ + patch.object(workspace_ops, 'cf_quotas', return_value=legacy_client) as client_factory: + rows = workspace_ops.quotas(SimpleNamespace(cli_ctx=object()), 'rg', 'ws') + + self.assertEqual(rows, [legacy_row]) + client_factory.assert_called_once() class QuantumWorkspaceUserListTest(unittest.TestCase): From cd1f80df669758d2a5306e6a266b061527c16e56 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Tue, 8 Sep 2026 11:38:07 -0700 Subject: [PATCH 15/24] [Quantum] Validate V2 workspace target quotas --- src/quantum/HISTORY.rst | 1 + src/quantum/azext_quantum/_help.py | 7 + src/quantum/azext_quantum/_params.py | 2 +- .../azext_quantum/operations/workspace.py | 101 ++++++- .../tests/latest/test_quantum_workspace.py | 264 +++++++++++++++++- 5 files changed, 361 insertions(+), 14 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index 8423c21fbb1..87445829190 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -7,6 +7,7 @@ Release History ++++++++++++++ * Added the ``az quantum suite-offer target list`` command to list the targets and their status available through a suite offer provider account, without requiring a workspace. * Updated the ``az quantum workspace quotas`` command to include v2 target quota allocations and usages while preserving the existing response format for v1 providers. +* Added always-on validation for V2 workspace target quota allocations on create and update, allowing final Standard and High values between current workspace usage and suite target allocation, inclusive. 1.0.0b26 ++++++++++++++ diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 0f1937ed38a..c6abdf57d9e 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -363,6 +363,9 @@ helps['quantum workspace create'] = """ type: command short-summary: Create a new Azure Quantum workspace. + long-summary: >- + Target quota values are absolute. For V2 workspaces, each final Standard and High allocation is validated + against the provider's suite target allocation before the workspace is created. examples: - name: Create a new Azure Quantum workspace with the providers that offer free credit. text: |- @@ -441,6 +444,10 @@ helps['quantum workspace update'] = """ type: command short-summary: Update the given (or current) Azure Quantum workspace. + long-summary: >- + Target quota values are absolute. Each final Standard and High allocation is validated against the current + workspace target usage and provider's suite target allocation, with equality allowed at both boundaries. + Priority values omitted from an existing target allocation are preserved and validated. examples: - name: Enable a provided Azure Quantum workspace api keys. text: |- diff --git a/src/quantum/azext_quantum/_params.py b/src/quantum/azext_quantum/_params.py index 283b8454581..9103a4afdf0 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -184,7 +184,7 @@ def load_arguments(self, _): # pylint: disable=too-many-locals entry_point_type = CLIArgumentType(help='The entry point for the QIR program or circuit. Required for some provider QIR jobs.') skip_autoadd_type = CLIArgumentType(help='If specified, the plans that offer free credits will not automatically be added.') workspace_kind_type = CLIArgumentType(options_list=['--workspace-kind'], help='The kind of the workspace to create.', choices=['V1', 'V2']) - quota_type = CLIArgumentType(options_list=['--quota'], help='Target quota allocation as provider-id, target-id, standard-minutes-lifetime, and optional high-minutes-lifetime key=value pairs, a JSON object or array, or `@{file}` with JSON content. standard-minutes-lifetime is required for a new allocation. camelCase keys (providerId, targetId, ...) are also accepted. Repeat --quota once per target.', action=QuotaAction, nargs='+') + quota_type = CLIArgumentType(options_list=['--quota'], help='Final target quota allocation for a V2 workspace as provider-id, target-id, standard-minutes-lifetime, and optional high-minutes-lifetime key=value pairs, a JSON object or array, or `@{file}` with JSON content. Use --workspace-kind V2 when creating a workspace. Values are absolute and cannot exceed the suite target allocation or, when updating, be below current workspace usage. standard-minutes-lifetime is required for a new allocation. camelCase keys (providerId, targetId, ...) are also accepted. Repeat --quota once per target.', action=QuotaAction, nargs='+') key_type = CLIArgumentType(options_list=['--key-type'], help='The api keys to be regenerated, should be Primary and/or Secondary.') enable_key_type = CLIArgumentType(options_list=['--enable-api-key'], help='Enable or disable API key authentication.') job_type_type = CLIArgumentType(options_list=['--job-type'], help='Job type to be listed, example "QuantumComputing".') diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 4dc6f58d54b..17ba57d2a39 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -23,7 +23,7 @@ MutuallyExclusiveArgumentError) from azure.core.exceptions import ResourceNotFoundError as AzureResourceNotFoundError -from .._client_factory import cf_workspaces, cf_quotas, cf_offerings, _get_data_credentials, base_url, base_url_v2 +from .._client_factory import cf_workspaces, cf_quotas, cf_offerings, cf_suite_offers, _get_data_credentials, base_url, base_url_v2 from .._list_helper import repack_response_json from ..vendored_sdks.azure_mgmt_quantum.models import QuantumWorkspace from ..vendored_sdks.azure_mgmt_quantum.models import ManagedServiceIdentity @@ -268,6 +268,102 @@ def _apply_target_quotas(providers, quota, preserve_existing=False): provider.target_quotas = target_quotas +_TARGET_QUOTA_PRIORITIES = ( + ("Standard", "standard_minutes_lifetime"), + ("High", "high_minutes_lifetime"), +) + + +def _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage): + if not quota: + return + + requested_keys = { + (allocation['providerId'].lower(), allocation['targetId'].lower()) + for allocation in quota + } + workspace_providers = { + provider.provider_id.lower(): provider + for provider in workspace.properties.providers or [] + if provider.provider_id + } + suite_offers = { + offer.properties.provider_id.lower(): offer + for offer in cf_suite_offers(cmd.cli_ctx).list_by_subscription() + if offer.properties is not None and offer.properties.provider_id + } + + final_targets = {} + suite_targets = {} + for provider_id, target_id in sorted(requested_keys): + provider = workspace_providers[provider_id] + target_quota = next( + item for item in provider.target_quotas or [] + if item.target_id is not None and item.target_id.lower() == target_id + ) + final_targets[(provider_id, target_id)] = target_quota + suite_offer = suite_offers.get(provider_id) + if suite_offer is None: + raise InvalidArgumentValueError( + f"Cannot validate --quota because no suite offer was found for provider '{provider.provider_id}'. " + "Run 'az quantum suite-offer list' to view available suite offers." + ) + suite_target = next( + (item for item in suite_offer.properties.target_quotas or [] + if item.target_id is not None and item.target_id.lower() == target_id), + None + ) + if suite_target is None: + raise InvalidArgumentValueError( + f"Cannot validate --quota because target '{target_quota.target_id}' was not found in the " + f"suite offer for provider '{provider.provider_id}'. Run 'az quantum suite-offer quotas " + f"--provider-id {provider.provider_id}' to view available target allocations." + ) + for priority, attribute in _TARGET_QUOTA_PRIORITIES: + if getattr(target_quota, attribute, None) is not None and getattr(suite_target, attribute, None) is None: + raise InvalidArgumentValueError( + f"Cannot validate the {priority} allocation for provider '{provider.provider_id}', target " + f"'{target_quota.target_id}', because the suite offer has no {priority} allocation." + ) + suite_targets[(provider_id, target_id)] = suite_target + + usage_by_key = {} + if include_usage: + usage_client = cf_quotas( + cmd.cli_ctx, info.subscription, info.resource_group, info.name, base_url_v2(workspace.location)) + for provider_id in sorted({provider_id for provider_id, _ in requested_keys}): + provider = workspace_providers[provider_id] + try: + usages = usage_client.list_quota_usages( + info.subscription, info.resource_group, info.name, provider.provider_id) + except AzureResourceNotFoundError: + usages = None + for usage in usages or []: + if usage.target_id is not None: + usage_by_key[(provider_id, usage.target_id.lower())] = usage.usage + + for provider_id, target_id in sorted(requested_keys): + provider = workspace_providers[provider_id] + target_quota = final_targets[(provider_id, target_id)] + suite_target = suite_targets[(provider_id, target_id)] + + usage = usage_by_key.get((provider_id, target_id)) + for priority, attribute in _TARGET_QUOTA_PRIORITIES: + final_allocation = getattr(target_quota, attribute, None) + if final_allocation is None: + continue + suite_allocation = getattr(suite_target, attribute) + current_usage = getattr(usage, attribute, None) if usage is not None else None + current_usage = current_usage if current_usage is not None else 0 + if final_allocation < current_usage or final_allocation > suite_allocation: + raise InvalidArgumentValueError( + f"The final {priority} allocation for provider '{provider.provider_id}', target " + f"'{target_quota.target_id}' is {final_allocation} minutes. It must be between the current " + f"workspace usage ({current_usage} minutes) and suite allocation ({suite_allocation} minutes), " + "inclusive." + ) + + def create(cmd, resource_group_name, workspace_name, location, storage_account, skip_role_assignment=False, provider_sku_list=None, auto_accept=False, skip_autoadd=False, workspace_kind=None, quota=None): """ @@ -291,6 +387,7 @@ def create(cmd, resource_group_name, workspace_name, location, storage_account, if skip_role_assignment: _add_quantum_providers(cmd, quantum_workspace, provider_sku_list, auto_accept, skip_autoadd) _apply_target_quotas(quantum_workspace.properties.providers, quota) + _validate_target_quota_bounds(cmd, info, quantum_workspace, quota, include_usage=False) quantum_workspace.properties.api_key_enabled = True if workspace_kind: quantum_workspace.properties.workspace_kind = workspace_kind @@ -308,6 +405,7 @@ def create(cmd, resource_group_name, workspace_name, location, storage_account, _add_quantum_providers(cmd, quantum_workspace, provider_sku_list, auto_accept, skip_autoadd) _apply_target_quotas(quantum_workspace.properties.providers, quota) + _validate_target_quota_bounds(cmd, info, quantum_workspace, quota, include_usage=False) validated_providers = [] for provider in quantum_workspace.properties.providers: provider_data = {"providerId": provider.provider_id, "providerSku": provider.provider_sku} @@ -622,6 +720,7 @@ def update(cmd, resource_group_name=None, workspace_name=None, enable_key=None, if quota: _require_v2_workspace(ws.properties.workspace_kind) _apply_target_quotas(ws.properties.providers, quota, preserve_existing=True) + _validate_target_quota_bounds(cmd, info, ws, quota, include_usage=True) if enable_key in ["True", "true"]: ws.properties.api_key_enabled = True diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 2573a309abb..5e984cabeb4 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -6,6 +6,7 @@ import os import argparse import pytest +import re import unittest import time from types import SimpleNamespace @@ -14,13 +15,14 @@ from azure.cli.testsdk.scenario_tests import AllowLargeResponse, live_only from azure.cli.testsdk import (ScenarioTest, ResourceGroupPreparer) from azure.cli.core.azclierror import RequiredArgumentMissingError, ResourceNotFoundError, InvalidArgumentValueError, ForbiddenError, ServiceError +from azure.core.exceptions import ResourceNotFoundError as AzureResourceNotFoundError from azure.cli.command_modules.role._msgrpah._graph_client import GraphError from .utils import get_test_resource_group, get_test_workspace, get_test_workspace_location, get_test_workspace_storage, get_test_workspace_storage_grs, get_test_workspace_random_name, get_test_workspace_random_long_name, get_test_capabilities, get_test_workspace_provider_sku_list, get_test_workspace_v2_provider_sku_list, all_providers_are_in_capabilities, issue_cmd_with_param_missing from ..._version_check_helper import check_version from ..._params import QuotaAction from datetime import datetime from ...__init__ import CLI_REPORTED_VERSION -from ...operations.workspace import _apply_target_quotas, _require_v2_workspace, _validate_storage_account, _autoadd_providers, list_users, update, QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, QUANTUM_WORKSPACE_OWNER_ROLE_ID, SUPPORTED_STORAGE_SKU_TIERS, SUPPORTED_STORAGE_KINDS, DEPLOYMENT_NAME_PREFIX +from ...operations.workspace import _apply_target_quotas, _require_v2_workspace, _validate_target_quota_bounds, _validate_storage_account, _autoadd_providers, list_users, update, QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, QUANTUM_WORKSPACE_OWNER_ROLE_ID, SUPPORTED_STORAGE_SKU_TIERS, SUPPORTED_STORAGE_KINDS, DEPLOYMENT_NAME_PREFIX from ...operations.workspace import _merge_workspace_quotas from ...commands import transform_workspace_quotas from ...vendored_sdks.azure_mgmt_quantum.models import Provider, TargetQuotaAllocations @@ -565,6 +567,9 @@ def test_target_quota_validation_errors(self): with self.assertRaises(InvalidArgumentValueError): _require_v2_workspace('V1') + with self.assertRaises(InvalidArgumentValueError): + _require_v2_workspace(None) + with self.assertRaises(InvalidArgumentValueError): _apply_target_quotas([Provider(provider_id='provider')], [{ 'providerId': 'other-provider', @@ -579,6 +584,203 @@ def test_target_quota_validation_errors(self): 'highMinutesLifetime': 50 }]) + def test_target_quota_bounds_allow_equality_and_match_case_insensitively(self): + provider = Provider(provider_id='Provider', target_quotas=[TargetQuotaAllocations( + target_id='Provider.Target', standard_minutes_lifetime=25, high_minutes_lifetime=100 + )]) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + suite_offer = SimpleNamespace(properties=SimpleNamespace( + provider_id='PROVIDER', target_quotas=[TargetQuotaAllocations( + target_id='PROVIDER.TARGET', standard_minutes_lifetime=100, high_minutes_lifetime=100 + )] + )) + usage = SimpleNamespace( + target_id='provider.target', + usage=SimpleNamespace(standard_minutes_lifetime=25, high_minutes_lifetime=10) + ) + info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') + cmd = SimpleNamespace(cli_ctx=object()) + + from ...operations import workspace as workspace_ops + with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ + patch.object(workspace_ops, 'cf_quotas') as quota_factory: + suite_factory.return_value.list_by_subscription.return_value = [suite_offer] + quota_factory.return_value.list_quota_usages.return_value = [usage] + + _validate_target_quota_bounds(cmd, info, workspace, [{ + 'providerId': 'provider', 'targetId': 'provider.target' + }], include_usage=True) + + quota_factory.return_value.list_quota_usages.assert_called_once_with( + 'sub', 'group', 'workspace', 'Provider') + + def test_target_quota_bounds_reject_values_outside_inclusive_range(self): + info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') + cmd = SimpleNamespace(cli_ctx=object()) + + for final_value, usage_value, expected_text in ( + (24, 25, 'current workspace usage (25 minutes)'), + (25, 25.5, 'current workspace usage (25.5 minutes)'), + (101, 25, 'suite allocation (100 minutes)')): + provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=final_value + )]) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + suite_offer = SimpleNamespace(properties=SimpleNamespace( + provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=100 + )] + )) + usage = SimpleNamespace( + target_id='provider.target', + usage=SimpleNamespace(standard_minutes_lifetime=usage_value, high_minutes_lifetime=None) + ) + + from ...operations import workspace as workspace_ops + with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ + patch.object(workspace_ops, 'cf_quotas') as quota_factory: + suite_factory.return_value.list_by_subscription.return_value = [suite_offer] + quota_factory.return_value.list_quota_usages.return_value = [usage] + + with self.assertRaisesRegex(InvalidArgumentValueError, re.escape(expected_text)): + _validate_target_quota_bounds(cmd, info, workspace, [{ + 'providerId': 'provider', 'targetId': 'provider.target' + }], include_usage=True) + + def test_target_quota_bounds_validate_high_priority_independently(self): + provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=50, high_minutes_lifetime=21 + )]) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + suite_offer = SimpleNamespace(properties=SimpleNamespace( + provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=100, high_minutes_lifetime=20 + )] + )) + usage = SimpleNamespace( + target_id='provider.target', + usage=SimpleNamespace(standard_minutes_lifetime=50, high_minutes_lifetime=5) + ) + info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') + cmd = SimpleNamespace(cli_ctx=object()) + + from ...operations import workspace as workspace_ops + with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ + patch.object(workspace_ops, 'cf_quotas') as quota_factory: + suite_factory.return_value.list_by_subscription.return_value = [suite_offer] + quota_factory.return_value.list_quota_usages.return_value = [usage] + + with self.assertRaisesRegex(InvalidArgumentValueError, 'final High allocation'): + _validate_target_quota_bounds(cmd, info, workspace, [{ + 'providerId': 'provider', 'targetId': 'provider.target' + }], include_usage=True) + + def test_target_quota_bounds_require_matching_suite_capacity(self): + provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=50, high_minutes_lifetime=10 + )]) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') + cmd = SimpleNamespace(cli_ctx=object()) + + cases = ( + ([], "no suite offer was found for provider 'provider'"), + ([SimpleNamespace(properties=SimpleNamespace(provider_id='provider', target_quotas=[]))], + "target 'provider.target' was not found"), + ([SimpleNamespace(properties=SimpleNamespace( + provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=100 + )] + ))], 'suite offer has no High allocation'), + ) + + from ...operations import workspace as workspace_ops + for suite_offers, expected_text in cases: + with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ + patch.object(workspace_ops, 'cf_quotas') as quota_factory: + suite_factory.return_value.list_by_subscription.return_value = suite_offers + with self.assertRaisesRegex(InvalidArgumentValueError, re.escape(expected_text)): + _validate_target_quota_bounds(cmd, info, workspace, [{ + 'providerId': 'provider', 'targetId': 'provider.target' + }], include_usage=True) + quota_factory.assert_not_called() + + def test_target_quota_bounds_query_usage_once_per_provider(self): + provider = Provider(provider_id='provider', target_quotas=[ + TargetQuotaAllocations(target_id='provider.target-1', standard_minutes_lifetime=50), + TargetQuotaAllocations(target_id='provider.target-2', standard_minutes_lifetime=50), + ]) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + suite_offer = SimpleNamespace(properties=SimpleNamespace( + provider_id='provider', target_quotas=[ + TargetQuotaAllocations(target_id='provider.target-1', standard_minutes_lifetime=100), + TargetQuotaAllocations(target_id='provider.target-2', standard_minutes_lifetime=100), + ] + )) + info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') + cmd = SimpleNamespace(cli_ctx=object()) + quota = [ + {'providerId': 'provider', 'targetId': 'provider.target-1'}, + {'providerId': 'provider', 'targetId': 'provider.target-2'}, + ] + + from ...operations import workspace as workspace_ops + with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ + patch.object(workspace_ops, 'cf_quotas') as quota_factory: + suite_factory.return_value.list_by_subscription.return_value = [suite_offer] + quota_factory.return_value.list_quota_usages.return_value = [] + + _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage=True) + + quota_factory.return_value.list_quota_usages.assert_called_once() + + def test_target_quota_bounds_treat_usage_404_as_zero(self): + provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=0 + )]) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + suite_offer = SimpleNamespace(properties=SimpleNamespace( + provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=100 + )] + )) + info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') + cmd = SimpleNamespace(cli_ctx=object()) + + from ...operations import workspace as workspace_ops + with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ + patch.object(workspace_ops, 'cf_quotas') as quota_factory: + suite_factory.return_value.list_by_subscription.return_value = [suite_offer] + quota_factory.return_value.list_quota_usages.side_effect = AzureResourceNotFoundError() + + _validate_target_quota_bounds(cmd, info, workspace, [{ + 'providerId': 'provider', 'targetId': 'provider.target' + }], include_usage=True) + + def test_target_quota_bounds_create_does_not_query_usage(self): + provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=100 + )]) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + suite_offer = SimpleNamespace(properties=SimpleNamespace( + provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=100 + )] + )) + info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') + cmd = SimpleNamespace(cli_ctx=object()) + + from ...operations import workspace as workspace_ops + with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ + patch.object(workspace_ops, 'cf_quotas') as quota_factory: + suite_factory.return_value.list_by_subscription.return_value = [suite_offer] + + _validate_target_quota_bounds(cmd, info, workspace, [{ + 'providerId': 'provider', 'targetId': 'provider.target' + }], include_usage=False) + + quota_factory.assert_not_called() + @unittest.mock.patch('azext_quantum.operations.workspace.WorkspaceInfo') @unittest.mock.patch('azext_quantum.operations.workspace.cf_workspaces') def test_update_target_quota_and_api_key(self, mock_cf_workspaces, mock_workspace_info): @@ -604,24 +806,62 @@ def test_update_target_quota_and_api_key(self, mock_cf_workspaces, mock_workspac info.resource_group = 'group' info.name = 'workspace' - result = update( - unittest.mock.MagicMock(), - resource_group_name='group', - workspace_name='workspace', - enable_key='true', - quota=[{ - 'providerId': 'provider', - 'targetId': 'provider.target', - 'standardMinutesLifetime': 0 - }] - ) + with patch('azext_quantum.operations.workspace._validate_target_quota_bounds') as validate_bounds: + result = update( + unittest.mock.MagicMock(), + resource_group_name='group', + workspace_name='workspace', + enable_key='true', + quota=[{ + 'providerId': 'provider', + 'targetId': 'provider.target', + 'standardMinutesLifetime': 0 + }] + ) assert result is workspace assert workspace.properties.api_key_enabled is True assert provider.target_quotas[0].standard_minutes_lifetime == 0 assert provider.target_quotas[0].high_minutes_lifetime == 50 + validate_bounds.assert_called_once() client.begin_create_or_update.assert_called_once_with('group', 'workspace', workspace) + @unittest.mock.patch('azext_quantum.operations.workspace.WorkspaceInfo') + @unittest.mock.patch('azext_quantum.operations.workspace.cf_workspaces') + def test_update_quota_validation_failure_prevents_write(self, mock_cf_workspaces, mock_workspace_info): + provider = Provider( + provider_id='provider', + target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=500, high_minutes_lifetime=50 + )] + ) + workspace = SimpleNamespace( + location='eastus', + properties=SimpleNamespace(workspace_kind='V2', providers=[provider], api_key_enabled=False) + ) + client = mock_cf_workspaces.return_value + client.get.return_value = workspace + info = mock_workspace_info.return_value + info.resource_group = 'group' + info.name = 'workspace' + + with patch('azext_quantum.operations.workspace._validate_target_quota_bounds', + side_effect=InvalidArgumentValueError('invalid quota')): + with self.assertRaisesRegex(InvalidArgumentValueError, 'invalid quota'): + update( + unittest.mock.MagicMock(), + resource_group_name='group', + workspace_name='workspace', + quota=[{ + 'providerId': 'provider', + 'targetId': 'provider.target', + 'standardMinutesLifetime': 100 + }] + ) + + assert provider.target_quotas[0].high_minutes_lifetime == 50 + client.begin_create_or_update.assert_not_called() + def test_autoadd_providers(self): print("test_autoadd_providers") test_managed_application = TestManagedApplicationDescription(None, None) From cd35c76adf5e21d2553b50d680ea4e2389da274f Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Tue, 8 Sep 2026 12:25:09 -0700 Subject: [PATCH 16/24] [Quantum] Remove provider from suite target table --- src/quantum/HISTORY.rst | 1 + src/quantum/azext_quantum/commands.py | 14 +++++++++++++- .../tests/latest/test_quantum_suite_offers.py | 7 +++---- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index 87445829190..f9dabac5ac4 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -6,6 +6,7 @@ Release History 1.0.0b27 ++++++++++++++ * Added the ``az quantum suite-offer target list`` command to list the targets and their status available through a suite offer provider account, without requiring a workspace. +* Removed the redundant provider column from the ``az quantum suite-offer target list`` table output. * Updated the ``az quantum workspace quotas`` command to include v2 target quota allocations and usages while preserving the existing response format for v1 providers. * Added always-on validation for V2 workspace target quota allocations on create and update, allowing final Standard and High values between current workspace usage and suite target allocation, inclusive. diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index d1f69699317..2d5ae04a28f 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -30,6 +30,18 @@ def one(provider, target): ] +def transform_suite_offer_targets(providers): + return [ + OrderedDict([ + ('Target-id', target['id']), + ('Current Availability', target['currentAvailability']), + ('Average Queue Time (seconds)', target['averageQueueTime']) + ]) + for provider in providers + for target in provider['targets'] + ] + + def transform_job(result): transformed_result = OrderedDict([ ('Name', result['name']), @@ -257,4 +269,4 @@ def load_command_table(self, _): s.command('quotas', 'suite_offer_quotas', table_transformer=transform_suite_offer_quotas) with self.command_group('quantum suite-offer target', suite_offers_ops) as st: - st.command('list', 'suite_offer_targets', table_transformer=transform_targets) + st.command('list', 'suite_offer_targets', table_transformer=transform_suite_offer_targets) diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index e394edafc28..097efeab6f6 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -8,7 +8,7 @@ from azure.cli.testsdk.scenario_tests import live_only from azure.cli.testsdk import ScenarioTest -from ...commands import transform_suite_offers, transform_suite_offer_quotas, transform_targets +from ...commands import transform_suite_offers, transform_suite_offer_quotas, transform_suite_offer_targets from ..._client_factory import base_url_v2 from ...operations.suite_offers import _merge_suite_offer_quotas from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsage, ProviderStatus @@ -221,14 +221,13 @@ def test_transform_targets_suite_offer_shape(self): } ] - table = transform_targets(providers) + table = transform_suite_offer_targets(providers) self.assertEqual(len(table), 1) row = table[0] self.assertEqual(list(row.keys()), [ - 'Provider', 'Target-id', 'Current Availability', 'Average Queue Time (seconds)' + 'Target-id', 'Current Availability', 'Average Queue Time (seconds)' ]) - self.assertEqual(row['Provider'], 'ionq') self.assertEqual(row['Target-id'], 'ionq.qpu') self.assertEqual(row['Current Availability'], 'Available') self.assertEqual(row['Average Queue Time (seconds)'], 42) From aea1be2b67fe115de7dfec4f90332a49982e5dee Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Tue, 8 Sep 2026 15:35:49 -0700 Subject: [PATCH 17/24] [Quantum] Add priority queue times to suite targets --- src/quantum/HISTORY.rst | 2 +- src/quantum/azext_quantum/_help.py | 5 +- src/quantum/azext_quantum/commands.py | 4 +- .../azext_quantum/operations/suite_offers.py | 16 +- .../azext_quantum/operations/workspace.py | 24 +- .../tests/latest/test_quantum_suite_offers.py | 59 +- .../tests/latest/test_quantum_workspace.py | 26 +- .../azure_quantum_python/_client/_client.py | 14 +- .../_client/_configuration.py | 8 +- .../azure_quantum_python/_client/_patch.py | 1 - .../_client/_utils/model_base.py | 614 +++++++++++++-- .../_client/_utils/serialization.py | 144 +++- .../azure_quantum_python/_client/_version.py | 2 +- .../_client/aio/_client.py | 14 +- .../_client/aio/_configuration.py | 8 +- .../_client/aio/_patch.py | 1 - .../_client/aio/operations/_operations.py | 633 +++++++++------ .../_client/aio/operations/_patch.py | 1 - .../_client/models/__init__.py | 10 +- .../_client/models/_enums.py | 11 + .../_client/models/_models.py | 137 ++-- .../_client/models/_patch.py | 1 - .../_client/operations/_operations.py | 743 +++++++++++------- .../_client/operations/_patch.py | 1 - .../azure_quantum_python/_client/py.typed | 1 + .../azure_quantum_python/_client/types.py | 431 ++++++++++ src/quantum/setup.py | 5 +- 27 files changed, 2181 insertions(+), 735 deletions(-) create mode 100644 src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/py.typed create mode 100644 src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/types.py diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index f9dabac5ac4..fa416e1ca41 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -5,7 +5,7 @@ Release History 1.0.0b27 ++++++++++++++ -* Added the ``az quantum suite-offer target list`` command to list the targets and their status available through a suite offer provider account, without requiring a workspace. +* Added the ``az quantum suite-offer target list`` command to list the targets, availability, and overall, Standard, and High average queue times available through a suite offer provider account, without requiring a workspace. * Removed the redundant provider column from the ``az quantum suite-offer target list`` table output. * Updated the ``az quantum workspace quotas`` command to include v2 target quota allocations and usages while preserving the existing response format for v1 providers. * Added always-on validation for V2 workspace target quota allocations on create and update, allowing final Standard and High values between current workspace usage and suite target allocation, inclusive. diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index c6abdf57d9e..2f94b3e9693 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -259,8 +259,9 @@ short-summary: List the targets and their status available through a suite offer, without requiring a workspace. long-summary: | Returns each target exposed by the suite offer provider account together with its current - availability and average queue time, resolved directly from the data plane without requiring - an Azure Quantum workspace. + availability and overall average queue time. Standard- and High-priority average queue times + are also returned when supplied by the provider. Data is resolved directly from the data plane + without requiring an Azure Quantum workspace. examples: - name: List the targets available in a suite offer. text: |- diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 2d5ae04a28f..7a69cae6a0a 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -35,7 +35,9 @@ def transform_suite_offer_targets(providers): OrderedDict([ ('Target-id', target['id']), ('Current Availability', target['currentAvailability']), - ('Average Queue Time (seconds)', target['averageQueueTime']) + ('Average Queue Time (seconds)', target['averageQueueTime']), + ('Average Standard Queue Time (seconds)', target.get('averageQueueTimeStandardPriority')), + ('Average High Queue Time (seconds)', target.get('averageQueueTimeHighPriority')) ]) for provider in providers for target in provider['targets'] diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py index d1c948a364f..70f410c85ed 100644 --- a/src/quantum/azext_quantum/operations/suite_offers.py +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -15,6 +15,18 @@ # Suite offer quota allocations are always reported at the per-target scope. _SUITE_OFFER_QUOTA_SCOPE = "SubscriptionTarget" +_QUOTA_USAGE_FIELDS = { + "standard_minutes_lifetime": "standardMinutesLifetime", + "high_minutes_lifetime": "highMinutesLifetime", +} + + +def _quota_usage_value(usage, attribute): + if usage is None: + return None + if hasattr(usage, "get"): + return usage.get(_QUOTA_USAGE_FIELDS[attribute]) + return getattr(usage, attribute, None) def list_suite_offers(cmd): @@ -128,8 +140,8 @@ def _merge_suite_offer_quotas(offer, usages, provider_id): target_quota.high_minutes_lifetime, ) row["usage"] = _minutes( - usage_values.standard_minutes_lifetime if usage_values is not None else None, - usage_values.high_minutes_lifetime if usage_values is not None else None, + _quota_usage_value(usage_values, "standard_minutes_lifetime"), + _quota_usage_value(usage_values, "high_minutes_lifetime"), ) rows.append(row) diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 17ba57d2a39..1ec3fcde079 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -272,6 +272,18 @@ def _apply_target_quotas(providers, quota, preserve_existing=False): ("Standard", "standard_minutes_lifetime"), ("High", "high_minutes_lifetime"), ) +_TARGET_QUOTA_USAGE_FIELDS = { + "standard_minutes_lifetime": "standardMinutesLifetime", + "high_minutes_lifetime": "highMinutesLifetime", +} + + +def _target_quota_usage_value(usage, attribute): + if usage is None: + return None + if hasattr(usage, "get"): + return usage.get(_TARGET_QUOTA_USAGE_FIELDS[attribute]) + return getattr(usage, attribute, None) def _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage): @@ -334,8 +346,8 @@ def _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage): for provider_id in sorted({provider_id for provider_id, _ in requested_keys}): provider = workspace_providers[provider_id] try: - usages = usage_client.list_quota_usages( - info.subscription, info.resource_group, info.name, provider.provider_id) + usages = usage_client.list_workspace_usages( + info.subscription, info.resource_group, info.name, provider_id=provider.provider_id) except AzureResourceNotFoundError: usages = None for usage in usages or []: @@ -353,7 +365,7 @@ def _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage): if final_allocation is None: continue suite_allocation = getattr(suite_target, attribute) - current_usage = getattr(usage, attribute, None) if usage is not None else None + current_usage = _target_quota_usage_value(usage, attribute) current_usage = current_usage if current_usage is not None else 0 if final_allocation < current_usage or final_allocation > suite_allocation: raise InvalidArgumentValueError( @@ -563,8 +575,8 @@ def quotas(cmd, resource_group_name, workspace_name): cmd.cli_ctx, info.subscription, info.resource_group, info.name, base_url_v2(workspace.location)) for provider in providers or []: try: - provider_usages = v2_client.list_quota_usages( - info.subscription, info.resource_group, info.name, provider.provider_id) + provider_usages = v2_client.list_workspace_usages( + info.subscription, info.resource_group, info.name, provider_id=provider.provider_id) except AzureResourceNotFoundError: provider_usages = None usages.extend(provider_usages or []) @@ -632,7 +644,7 @@ def _merge_workspace_quotas(workspace, usages, legacy_quotas=None): display_target_id, dimension, getattr(target_quota, attribute, None), - getattr(usage_values, attribute, None), + _target_quota_usage_value(usage_values, attribute), )) return rows diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index 097efeab6f6..8e336c31b3e 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -11,7 +11,7 @@ from ...commands import transform_suite_offers, transform_suite_offer_quotas, transform_suite_offer_targets from ..._client_factory import base_url_v2 from ...operations.suite_offers import _merge_suite_offer_quotas -from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsage, ProviderStatus +from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsageData, ProviderStatus from ...vendored_sdks.azure_quantum_python._client._utils.model_base import _deserialize from ...vendored_sdks.azure_quantum_python._client.operations._operations import ( build_services_suite_offers_list_quota_usages_request, @@ -123,7 +123,7 @@ def test_build_suite_offers_list_quota_usages_request(self): ) self.assertIn('api-version=2026-01-15-preview', request.url) - def test_deserialize_quota_usages_bare_array(self): + def test_deserialize_quota_usages(self): data = [ { 'id': 'usage-1', @@ -141,12 +141,12 @@ def test_deserialize_quota_usages_bare_array(self): }, ] - usages = _deserialize(list[QuotaUsage], data) + usages = _deserialize(list[QuotaUsageData], data) self.assertEqual(len(usages), 2) self.assertEqual(usages[0].scope, 'Subscription') self.assertIsNone(usages[0].target_id) - self.assertEqual(usages[0].usage.standard_minutes_lifetime, 40.0) + self.assertEqual(usages[0].usage['standardMinutesLifetime'], 40.0) self.assertEqual(usages[1].scope, 'SubscriptionTarget') self.assertEqual(usages[1].target_id, 'ionq.qpu') @@ -167,7 +167,14 @@ def test_deserialize_provider_status_single_object(self): 'id': 'ionq', 'currentAvailability': 'Available', 'targets': [ - {'id': 'ionq.qpu', 'currentAvailability': 'Available', 'averageQueueTime': 42}, + { + 'id': 'ionq.qpu', + 'currentAvailability': 'Available', + 'averageQueueTime': 42, + 'averageQueueTimeHighPriority': 10, + 'averageQueueTimeStandardPriority': 60, + }, + {'id': 'ionq.simulator', 'currentAvailability': 'Available', 'averageQueueTime': 0}, ], } @@ -175,9 +182,13 @@ def test_deserialize_provider_status_single_object(self): self.assertEqual(provider.id, 'ionq') self.assertEqual(provider.current_availability, 'Available') - self.assertEqual(len(provider.targets), 1) + self.assertEqual(len(provider.targets), 2) self.assertEqual(provider.targets[0].id, 'ionq.qpu') self.assertEqual(provider.targets[0].average_queue_time, 42) + self.assertEqual(provider.targets[0].average_queue_time_high_priority, 10) + self.assertEqual(provider.targets[0].average_queue_time_standard_priority, 60) + self.assertIsNone(provider.targets[1].average_queue_time_high_priority) + self.assertIsNone(provider.targets[1].average_queue_time_standard_priority) def test_get_provider_status_returns_single_object(self): # The service returns a single ProviderStatus object, not a paged envelope or array. @@ -185,7 +196,13 @@ def test_get_provider_status_returns_single_object(self): 'id': 'ionq', 'currentAvailability': 'Available', 'targets': [ - {'id': 'ionq.qpu', 'currentAvailability': 'Available', 'averageQueueTime': 7}, + { + 'id': 'ionq.qpu', + 'currentAvailability': 'Available', + 'averageQueueTime': 7, + 'averageQueueTimeHighPriority': 2, + 'averageQueueTimeStandardPriority': 9, + }, ], } http_response = SimpleNamespace(status_code=200, json=lambda: single) @@ -209,6 +226,8 @@ def test_get_provider_status_returns_single_object(self): self.assertEqual(result.current_availability, 'Available') self.assertEqual(result.targets[0].id, 'ionq.qpu') self.assertEqual(result.targets[0].average_queue_time, 7) + self.assertEqual(result.targets[0].average_queue_time_high_priority, 2) + self.assertEqual(result.targets[0].average_queue_time_standard_priority, 9) def test_transform_targets_suite_offer_shape(self): providers = [ @@ -216,21 +235,33 @@ def test_transform_targets_suite_offer_shape(self): 'id': 'ionq', 'currentAvailability': 'Available', 'targets': [ - {'id': 'ionq.qpu', 'currentAvailability': 'Available', 'averageQueueTime': 42}, + { + 'id': 'ionq.qpu', + 'currentAvailability': 'Available', + 'averageQueueTime': 42, + 'averageQueueTimeHighPriority': 10, + 'averageQueueTimeStandardPriority': 60, + }, + {'id': 'ionq.simulator', 'currentAvailability': 'Available', 'averageQueueTime': 0}, ], } ] table = transform_suite_offer_targets(providers) - self.assertEqual(len(table), 1) + self.assertEqual(len(table), 2) row = table[0] self.assertEqual(list(row.keys()), [ - 'Target-id', 'Current Availability', 'Average Queue Time (seconds)' + 'Target-id', 'Current Availability', 'Average Queue Time (seconds)', + 'Average Standard Queue Time (seconds)', 'Average High Queue Time (seconds)' ]) self.assertEqual(row['Target-id'], 'ionq.qpu') self.assertEqual(row['Current Availability'], 'Available') self.assertEqual(row['Average Queue Time (seconds)'], 42) + self.assertEqual(row['Average Standard Queue Time (seconds)'], 60) + self.assertEqual(row['Average High Queue Time (seconds)'], 10) + self.assertIsNone(table[1]['Average Standard Queue Time (seconds)']) + self.assertIsNone(table[1]['Average High Queue Time (seconds)']) def test_merge_quotas_target_with_usage(self): offer = _offer( @@ -239,7 +270,13 @@ def test_merge_quotas_target_with_usage(self): ) usages = [ _usage(target_id=None, standard=40, high=10), # subscription-scope usage ignored - _usage(target_id='ionq.qpu', standard=5, high=2), + _deserialize(QuotaUsageData, { + 'providerId': 'ionq', + 'scope': 'SubscriptionTarget', + 'targetId': 'ionq.qpu', + 'usage': {'standardMinutesLifetime': 5, 'highMinutesLifetime': 2}, + 'lastModifiedTime': '2026-01-15T00:00:00Z', + }), ] rows = _merge_suite_offer_quotas(offer, usages, 'ionq') diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 5e984cabeb4..691b590ab60 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -27,7 +27,7 @@ from ...commands import transform_workspace_quotas from ...vendored_sdks.azure_mgmt_quantum.models import Provider, TargetQuotaAllocations from ...vendored_sdks.azure_quantum_python._client.operations._operations import ( - build_services_quotas_list_quota_usages_request, + build_services_quotas_list_workspace_usages_request, ) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) @@ -605,14 +605,14 @@ def test_target_quota_bounds_allow_equality_and_match_case_insensitively(self): with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ patch.object(workspace_ops, 'cf_quotas') as quota_factory: suite_factory.return_value.list_by_subscription.return_value = [suite_offer] - quota_factory.return_value.list_quota_usages.return_value = [usage] + quota_factory.return_value.list_workspace_usages.return_value = [usage] _validate_target_quota_bounds(cmd, info, workspace, [{ 'providerId': 'provider', 'targetId': 'provider.target' }], include_usage=True) - quota_factory.return_value.list_quota_usages.assert_called_once_with( - 'sub', 'group', 'workspace', 'Provider') + quota_factory.return_value.list_workspace_usages.assert_called_once_with( + 'sub', 'group', 'workspace', provider_id='Provider') def test_target_quota_bounds_reject_values_outside_inclusive_range(self): info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') @@ -640,7 +640,7 @@ def test_target_quota_bounds_reject_values_outside_inclusive_range(self): with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ patch.object(workspace_ops, 'cf_quotas') as quota_factory: suite_factory.return_value.list_by_subscription.return_value = [suite_offer] - quota_factory.return_value.list_quota_usages.return_value = [usage] + quota_factory.return_value.list_workspace_usages.return_value = [usage] with self.assertRaisesRegex(InvalidArgumentValueError, re.escape(expected_text)): _validate_target_quota_bounds(cmd, info, workspace, [{ @@ -668,7 +668,7 @@ def test_target_quota_bounds_validate_high_priority_independently(self): with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ patch.object(workspace_ops, 'cf_quotas') as quota_factory: suite_factory.return_value.list_by_subscription.return_value = [suite_offer] - quota_factory.return_value.list_quota_usages.return_value = [usage] + quota_factory.return_value.list_workspace_usages.return_value = [usage] with self.assertRaisesRegex(InvalidArgumentValueError, 'final High allocation'): _validate_target_quota_bounds(cmd, info, workspace, [{ @@ -728,11 +728,11 @@ def test_target_quota_bounds_query_usage_once_per_provider(self): with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ patch.object(workspace_ops, 'cf_quotas') as quota_factory: suite_factory.return_value.list_by_subscription.return_value = [suite_offer] - quota_factory.return_value.list_quota_usages.return_value = [] + quota_factory.return_value.list_workspace_usages.return_value = [] _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage=True) - quota_factory.return_value.list_quota_usages.assert_called_once() + quota_factory.return_value.list_workspace_usages.assert_called_once() def test_target_quota_bounds_treat_usage_404_as_zero(self): provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( @@ -751,7 +751,7 @@ def test_target_quota_bounds_treat_usage_404_as_zero(self): with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ patch.object(workspace_ops, 'cf_quotas') as quota_factory: suite_factory.return_value.list_by_subscription.return_value = [suite_offer] - quota_factory.return_value.list_quota_usages.side_effect = AzureResourceNotFoundError() + quota_factory.return_value.list_workspace_usages.side_effect = AzureResourceNotFoundError() _validate_target_quota_bounds(cmd, info, workspace, [{ 'providerId': 'provider', 'targetId': 'provider.target' @@ -900,7 +900,7 @@ class TestWorkspaceInfo(object): class QuantumWorkspaceQuotasTest(unittest.TestCase): def test_build_workspace_quotas_list_quota_usages_request(self): - request = build_services_quotas_list_quota_usages_request( + request = build_services_quotas_list_workspace_usages_request( subscription_id='00000000-0000-0000-0000-000000000000', resource_group_name='MyResourceGroup', workspace_name='MyWorkspace', @@ -1057,13 +1057,13 @@ def test_quotas_handler_queries_each_provider_and_merges(self): } queried = [] - def fake_list_quota_usages(*args): - provider_id = args[-1] + def fake_list_workspace_usages(*args, **kwargs): + provider_id = kwargs['provider_id'] queried.append(provider_id) return usage_by_provider[provider_id] legacy_client = SimpleNamespace(list=lambda *_: [legacy_row]) - v2_client = SimpleNamespace(list_quota_usages=fake_list_quota_usages) + v2_client = SimpleNamespace(list_workspace_usages=fake_list_workspace_usages) def fake_client_factory(*args): endpoint = args[-1] diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_client.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_client.py index e56d6c0ef19..25b8770282b 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_client.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_client.py @@ -7,8 +7,8 @@ # -------------------------------------------------------------------------- from copy import deepcopy +import sys from typing import Any, TYPE_CHECKING, Union -from typing_extensions import Self from azure.core import PipelineClient from azure.core.credentials import AzureKeyCredential @@ -19,11 +19,16 @@ from ._utils.serialization import Deserializer, Serializer from .operations import ServicesOperations +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self # type: ignore + if TYPE_CHECKING: from azure.core.credentials import TokenCredential -class WorkspaceClient: +class WorkspaceClient: # pylint: disable=docstring-keyword-should-match-keyword-only """Azure Quantum Workspace Services. :ivar services: ServicesOperations operations @@ -36,8 +41,9 @@ class WorkspaceClient: :type credential: ~azure.core.credentials.TokenCredential or ~azure.core.credentials.AzureKeyCredential :keyword api_version: The API version to use for this operation. Known values are - "2026-01-15-preview" and None. Default value is "2026-01-15-preview". Note that overriding this - default value may result in unsupported behavior. + "2026-01-15-preview" and None. Default value is None. If not set, the operation's default API + version will be used. Note that overriding this default value may result in unsupported + behavior. :paramtype api_version: str """ diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_configuration.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_configuration.py index 8346a6b9cc5..0fd8ece736e 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_configuration.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_configuration.py @@ -1,3 +1,4 @@ +# pylint: disable=line-too-long,useless-suppression # coding=utf-8 # -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. @@ -17,7 +18,7 @@ from azure.core.credentials import TokenCredential -class WorkspaceClientConfiguration: # pylint: disable=too-many-instance-attributes +class WorkspaceClientConfiguration: # pylint: disable=too-many-instance-attributes,docstring-keyword-should-match-keyword-only """Configuration for WorkspaceClient. Note that all parameters used to create this instance are saved as instance @@ -31,8 +32,9 @@ class WorkspaceClientConfiguration: # pylint: disable=too-many-instance-attribu :type credential: ~azure.core.credentials.TokenCredential or ~azure.core.credentials.AzureKeyCredential :keyword api_version: The API version to use for this operation. Known values are - "2026-01-15-preview" and None. Default value is "2026-01-15-preview". Note that overriding this - default value may result in unsupported behavior. + "2026-01-15-preview" and None. Default value is None. If not set, the operation's default API + version will be used. Note that overriding this default value may result in unsupported + behavior. :paramtype api_version: str """ diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_patch.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_patch.py index 87676c65a8f..ea765788358 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_patch.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_patch.py @@ -8,7 +8,6 @@ Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize """ - __all__: list[str] = [] # Add all objects you want publicly available to users at this package level diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_utils/model_base.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_utils/model_base.py index c402af2afc6..35d5fc02497 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_utils/model_base.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_utils/model_base.py @@ -23,14 +23,19 @@ from json import JSONEncoder import xml.etree.ElementTree as ET from collections.abc import MutableMapping -from typing_extensions import Self import isodate from azure.core.exceptions import DeserializationError from azure.core import CaseInsensitiveEnumMeta from azure.core.pipeline import PipelineResponse from azure.core.serialization import _Null + from azure.core.rest import HttpResponse +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + _LOGGER = logging.getLogger(__name__) __all__ = ["SdkJSONEncoder", "Model", "rest_field", "rest_discriminator"] @@ -104,6 +109,29 @@ def _serialize_bytes(o, format: typing.Optional[str] = None) -> str: return encoded +def _serialize_duration(td: timedelta, format: typing.Optional[str] = None): + """Serialize a timedelta to its wire representation. + + For the ``seconds``/``milliseconds`` encodings the value is converted to a + numeric value, otherwise it falls back to an ISO 8601 duration string. + + :param timedelta td: The timedelta to serialize. + :param str format: The duration encoding format. + :rtype: int or float or str + :return: serialized duration + """ + seconds = td.total_seconds() + if format == "duration-seconds-int": + return int(seconds) + if format == "duration-seconds-float": + return seconds + if format == "duration-milliseconds-int": + return int(seconds * 1000) + if format == "duration-milliseconds-float": + return seconds * 1000 + return _timedelta_as_isostr(td) + + def _serialize_datetime(o, format: typing.Optional[str] = None): if hasattr(o, "year") and hasattr(o, "hour"): if format == "rfc7231": @@ -130,7 +158,15 @@ def _is_readonly(p): class SdkJSONEncoder(JSONEncoder): - """A JSON encoder that's capable of serializing datetime objects and bytes.""" + """A JSON encoder that's capable of serializing datetime objects and bytes. + + :param args: Additional positional arguments passed to the base ``JSONEncoder``. + :type args: typing.Any + :keyword exclude_readonly: Whether to exclude readonly properties. Defaults to False. + :paramtype exclude_readonly: bool + :keyword format: The format to use for serialization. Defaults to None. + :paramtype format: typing.Optional[str] + """ def __init__(self, *args, exclude_readonly: bool = False, format: typing.Optional[str] = None, **kwargs): super().__init__(*args, **kwargs) @@ -296,6 +332,12 @@ def _deserialize_duration(attr): return isodate.parse_duration(attr) +def _deserialize_duration_numeric(attr, unit): + if isinstance(attr, timedelta): + return attr + return timedelta(**{unit: float(attr)}) + + def _deserialize_decimal(attr): if isinstance(attr, decimal.Decimal): return attr @@ -308,6 +350,12 @@ def _deserialize_int_as_str(attr): return int(attr) +def _deserialize_bool_as_str(attr): + if isinstance(attr, bool): + return attr + return attr.lower() == "true" + + _DESERIALIZE_MAPPING = { datetime: _deserialize_datetime, date: _deserialize_date, @@ -325,12 +373,18 @@ def _deserialize_int_as_str(attr): "unix-timestamp": _deserialize_datetime_unix_timestamp, "base64": _deserialize_bytes, "base64url": _deserialize_bytes_base64, + "duration-seconds-int": functools.partial(_deserialize_duration_numeric, unit="seconds"), + "duration-seconds-float": functools.partial(_deserialize_duration_numeric, unit="seconds"), + "duration-milliseconds-int": functools.partial(_deserialize_duration_numeric, unit="milliseconds"), + "duration-milliseconds-float": functools.partial(_deserialize_duration_numeric, unit="milliseconds"), } def get_deserializer(annotation: typing.Any, rf: typing.Optional["_RestField"] = None): if annotation is int and rf and rf._format == "str": return _deserialize_int_as_str + if annotation is bool and rf and rf._format == "str": + return _deserialize_bool_as_str if annotation is str and rf and rf._format in _ARRAY_ENCODE_MAPPING: return functools.partial(_deserialize_array_encoded, _ARRAY_ENCODE_MAPPING[rf._format]) if rf and rf._format: @@ -420,21 +474,21 @@ def __ne__(self, other: typing.Any) -> bool: def keys(self) -> typing.KeysView[str]: """ - :returns: a set-like object providing a view on D's keys + :returns: a set-like object providing a view on the mapping's keys :rtype: ~typing.KeysView """ return self._data.keys() def values(self) -> typing.ValuesView[typing.Any]: """ - :returns: an object providing a view on D's values + :returns: an object providing a view on the mapping's values :rtype: ~typing.ValuesView """ return self._data.values() def items(self) -> typing.ItemsView[str, typing.Any]: """ - :returns: set-like object providing a view on D's items + :returns: a set-like object providing a view on the mapping's items :rtype: ~typing.ItemsView """ return self._data.items() @@ -444,7 +498,7 @@ def get(self, key: str, default: typing.Any = None) -> typing.Any: Get the value for key if key is in the dictionary, else default. :param str key: The key to look up. :param any default: The value to return if key is not in the dictionary. Defaults to None - :returns: D[k] if k in D, else d. + :returns: The value for key if key is in the dictionary, else default. :rtype: any """ try: @@ -479,19 +533,19 @@ def popitem(self) -> tuple[str, typing.Any]: Removes and returns some (key, value) pair :returns: The (key, value) pair. :rtype: tuple - :raises KeyError: if D is empty. + :raises KeyError: if the dictionary is empty. """ return self._data.popitem() def clear(self) -> None: """ - Remove all items from D. + Remove all items from the dictionary. """ self._data.clear() def update(self, *args: typing.Any, **kwargs: typing.Any) -> None: # pylint: disable=arguments-differ """ - Updates D from mapping/iterable E and F. + Update the dictionary from a mapping or an iterable of key-value pairs. :param any args: Either a mapping object or an iterable of key-value pairs. """ self._data.update(*args, **kwargs) @@ -504,10 +558,11 @@ def setdefault(self, key: str, default: typing.Any) -> typing.Any: ... # pylint def setdefault(self, key: str, default: typing.Any = _UNSET) -> typing.Any: """ - Same as calling D.get(k, d), and setting D[k]=d if k not found + Return the value for key if key is in the dictionary; otherwise set the key to + default and return default. :param str key: The key to look up. :param any default: The value to set if key is not in the dictionary - :returns: D[k] if k in D, else d. + :returns: The value for key if key is in the dictionary, else default. :rtype: any """ if default is _UNSET: @@ -515,6 +570,8 @@ def setdefault(self, key: str, default: typing.Any = _UNSET) -> typing.Any: return self._data.setdefault(key, default) def __eq__(self, other: typing.Any) -> bool: + if isinstance(other, _MyMutableMapping): + return self._data == other._data try: other_model = self.__class__(other) except Exception: @@ -557,7 +614,7 @@ def _serialize(o, format: typing.Optional[str] = None): # pylint: disable=too-m pass # Last, try datetime.timedelta try: - return _timedelta_as_isostr(o) + return _serialize_duration(o, format) except AttributeError: # This will be raised when it hits value.total_seconds in the method above pass @@ -583,6 +640,239 @@ def _create_value(rf: typing.Optional["_RestField"], value: typing.Any) -> typin return _serialize(value, rf._format) +# ============================================================================ +# Fast-path scalar deserializer functions for rest_field(deserializer=...) +# These are referenced from rest_field declarations to bypass the generic +# _deserialize -> _deserialize_with_callable chain. +# Only simple/primitive types — no models or container types. +# ============================================================================ + + +def _xml_deser_str(value): + if isinstance(value, ET.Element): + return value.text or "" + return str(value) if value is not None else None + + +def _xml_deser_int(value): + if isinstance(value, ET.Element): + return int(value.text) if value.text else None + return int(value) if value is not None else None + + +def _xml_deser_float(value): + if isinstance(value, ET.Element): + return float(value.text) if value.text else None + return float(value) if value is not None else None + + +def _xml_deser_bool(value): + if isinstance(value, ET.Element): + text = value.text + else: + text = value + if text is None: + return None + if text in (True, False): + return text + return text.lower() == "true" + + +# pylint: disable=docstring-missing-param +def _xml_deser_bytes(value): + """Deserialize bytes from XML (base64).""" + if isinstance(value, ET.Element): + text = value.text + else: + text = value + if text is None: + return None + return _deserialize_bytes(text) + + +def _xml_deser_bytes_base64url(value): + """Deserialize bytes from XML (base64url).""" + if isinstance(value, ET.Element): + text = value.text + else: + text = value + if text is None: + return None + return _deserialize_bytes_base64(text) + + +def _xml_deser_datetime(value): + """Deserialize a datetime from XML (ISO 8601 / rfc3339).""" + if isinstance(value, ET.Element): + text = value.text + else: + text = value + if text is None: + return None + return _deserialize_datetime(text) + + +def _xml_deser_datetime_rfc7231(value): + """Deserialize a datetime from XML (RFC7231 format).""" + if isinstance(value, ET.Element): + text = value.text + else: + text = value + if text is None: + return None + return _deserialize_datetime_rfc7231(text) + + +def _xml_deser_datetime_unix_timestamp(value): + """Deserialize a datetime from XML (Unix timestamp).""" + if isinstance(value, ET.Element): + text = value.text + else: + text = value + if text is None: + return None + return _deserialize_datetime_unix_timestamp(float(text)) + + +def _xml_deser_date(value): + """Deserialize a date from XML (ISO 8601).""" + if isinstance(value, ET.Element): + text = value.text + else: + text = value + if text is None: + return None + return _deserialize_date(text) + + +def _xml_deser_time(value): + """Deserialize a time from XML (ISO 8601).""" + if isinstance(value, ET.Element): + text = value.text + else: + text = value + if text is None: + return None + return _deserialize_time(text) + + +def _xml_deser_duration(value): + """Deserialize a timedelta from XML (ISO 8601 duration).""" + if isinstance(value, ET.Element): + text = value.text + else: + text = value + if text is None: + return None + return _deserialize_duration(text) + + +def _xml_deser_decimal(value): + """Deserialize a Decimal from XML.""" + if isinstance(value, ET.Element): + text = value.text + else: + text = value + if text is None: + return None + return _deserialize_decimal(text) + + +def _xml_deser_enum_or_str(enum_cls, value): + """Deserialize a Union[EnumType, str] from XML.""" + text = value.text if isinstance(value, ET.Element) else value + if text is None: + return None + try: + return enum_cls(text) + except ValueError: + return text + + +def _extract_xml_model_type(rf_type): + """Extract the concrete Model class from a resolved rf._type partial chain. + + Unwraps ``Optional[Model]`` and ``_deserialize_model(Model, ...)`` + wrappers. Only handles Model and Optional[Model] — other composite + types (List, Dict, Union, etc.) return None and fall through to the + generic ``_deserialize`` path at runtime. + """ + if rf_type is None: + return None + if isinstance(rf_type, type) and _is_model(rf_type): + return rf_type + if not isinstance(rf_type, functools.partial): + return None + func = rf_type.func + args = rf_type.args + if func is _deserialize_with_optional and args: + return _extract_xml_model_type(args[0]) + if func is _deserialize_model and args: + cls = args[0] + return cls if isinstance(cls, type) and _is_model(cls) else None + return None + + +def _build_xml_field_plan( # pylint: disable=docstring-missing-return, docstring-missing-rtype, unused-variable + cls, attr_to_rest_field: dict +) -> list: + """Build a precomputed XML field plan for fast _init_from_xml iteration. + + Called once per model class in __new__. Returns a list of tuples: + (rest_name, xml_name, kind, deser, rf_type, is_optional, items_name) + + kind: 0=wrapped, 1=attribute, 2=unwrapped, 3=text + + For Model and Optional[Model] fields that lack a scalar + ``_deserializer``, this function precomputes the Model class as the + deserializer so ``_init_from_xml`` can call ``ModelClass(element)`` + directly instead of going through the expensive + ``_get_deserialize_callable_from_annotation`` chain at runtime. + """ + model_meta = getattr(cls, "_xml", {}) + model_ns = model_meta.get("ns") or model_meta.get("namespace") + plan = [] + + for rf in attr_to_rest_field.values(): + prop_meta = getattr(rf, "_xml", {}) + deser = rf._deserializer + + xml_name = prop_meta.get("name", rf._rest_name) + xml_ns = _resolve_xml_ns(prop_meta, model_meta) + if xml_ns: + xml_name = "{" + xml_ns + "}" + xml_name + + is_optional = rf._is_optional + + # For Model / Optional[Model] fields without a scalar deserializer, + # precompute the Model class as the deserializer. + if deser is None and rf._type is not None: + model_cls = _extract_xml_model_type(rf._type) + if model_cls is not None: + deser = model_cls + + if prop_meta.get("attribute", False): + plan.append((rf._rest_name, xml_name, 1, deser, rf._type, is_optional, None)) + elif prop_meta.get("unwrapped", False): + items_name = prop_meta.get("itemsName") + if items_name: + items_ns = prop_meta.get("itemsNs") + if items_ns is not None: + xml_ns = items_ns + if xml_ns: + items_name = "{" + xml_ns + "}" + items_name + else: + items_name = xml_name + plan.append((rf._rest_name, xml_name, 2, deser, rf._type, is_optional, items_name)) + elif prop_meta.get("text", False): + plan.append((rf._rest_name, xml_name, 3, deser, rf._type, is_optional, None)) + else: + plan.append((rf._rest_name, xml_name, 0, deser, rf._type, is_optional, None)) + + return plan + + +# pylint: enable=docstring-missing-param class Model(_MyMutableMapping): _is_model = True # label whether current class's _attr_to_rest_field has been calculated @@ -593,59 +883,10 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: class_name = self.__class__.__name__ if len(args) > 1: raise TypeError(f"{class_name}.__init__() takes 2 positional arguments but {len(args) + 1} were given") - dict_to_pass = { - rest_field._rest_name: rest_field._default - for rest_field in self._attr_to_rest_field.values() - if rest_field._default is not _UNSET - } - if args: # pylint: disable=too-many-nested-blocks + dict_to_pass: dict[str, typing.Any] = {} + if args: if isinstance(args[0], ET.Element): - existed_attr_keys = [] - model_meta = getattr(self, "_xml", {}) - - for rf in self._attr_to_rest_field.values(): - prop_meta = getattr(rf, "_xml", {}) - xml_name = prop_meta.get("name", rf._rest_name) - xml_ns = prop_meta.get("ns", model_meta.get("ns", None)) - if xml_ns: - xml_name = "{" + xml_ns + "}" + xml_name - - # attribute - if prop_meta.get("attribute", False) and args[0].get(xml_name) is not None: - existed_attr_keys.append(xml_name) - dict_to_pass[rf._rest_name] = _deserialize(rf._type, args[0].get(xml_name)) - continue - - # unwrapped element is array - if prop_meta.get("unwrapped", False): - # unwrapped array could either use prop items meta/prop meta - if prop_meta.get("itemsName"): - xml_name = prop_meta.get("itemsName") - xml_ns = prop_meta.get("itemNs") - if xml_ns: - xml_name = "{" + xml_ns + "}" + xml_name - items = args[0].findall(xml_name) # pyright: ignore - if len(items) > 0: - existed_attr_keys.append(xml_name) - dict_to_pass[rf._rest_name] = _deserialize(rf._type, items) - continue - - # text element is primitive type - if prop_meta.get("text", False): - if args[0].text is not None: - dict_to_pass[rf._rest_name] = _deserialize(rf._type, args[0].text) - continue - - # wrapped element could be normal property or array, it should only have one element - item = args[0].find(xml_name) - if item is not None: - existed_attr_keys.append(xml_name) - dict_to_pass[rf._rest_name] = _deserialize(rf._type, item) - - # rest thing is additional properties - for e in args[0]: - if e.tag not in existed_attr_keys: - dict_to_pass[e.tag] = _convert_element(e) + dict_to_pass.update(self._init_from_xml(args[0])) else: dict_to_pass.update( {k: _create_value(_get_rest_field(self._attr_to_rest_field, k), v) for k, v in args[0].items()} @@ -662,8 +903,117 @@ def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: if v is not None } ) + # Apply client default values for fields the caller didn't set so that + # defaults are part of `_data` and therefore included during serialization. + for rf in self._attr_to_rest_field.values(): + if rf._default is _UNSET: + continue + if rf._rest_name in dict_to_pass: + continue + dict_to_pass[rf._rest_name] = _create_value(rf, rf._default) super().__init__(dict_to_pass) + def _init_from_xml( # pylint: disable=too-many-branches, too-many-statements + self, element: ET.Element + ) -> dict[str, typing.Any]: + """Deserialize an XML element into a dict mapping rest field names to values. + + :param ET.Element element: The XML element to deserialize from. + :returns: A dictionary of rest_name to deserialized value pairs. + :rtype: dict + """ + result: dict[str, typing.Any] = {} + existed_attr_keys: list[str] = [] + + field_plan = getattr(self, "_xml_field_plan", None) + if field_plan: + for rest_name, xml_name, kind, deser, rf_type, is_optional, items_name in field_plan: + if kind == 0: # wrapped element (most common) + item = element.find(xml_name) + if item is not None: + existed_attr_keys.append(xml_name) + if deser: + result[rest_name] = deser(item) + else: + result[rest_name] = _deserialize(rf_type, item) + elif kind == 1: # attribute + attr_val = element.get(xml_name) + if attr_val is not None: + existed_attr_keys.append(xml_name) + if deser: + result[rest_name] = deser(attr_val) + else: + result[rest_name] = attr_val + elif kind == 2: # unwrapped array + items = element.findall(items_name) # pyright: ignore + if len(items) > 0: + existed_attr_keys.append(items_name) + if deser: + result[rest_name] = deser(items) + else: + result[rest_name] = _deserialize(rf_type, items) + elif not is_optional: + existed_attr_keys.append(items_name) + result[rest_name] = [] + elif kind == 3: # text + if element.text is not None: + if deser: + result[rest_name] = deser(element.text) + else: + result[rest_name] = element.text + else: + model_meta = getattr(self, "_xml", {}) + for rf in self._attr_to_rest_field.values(): + prop_meta = getattr(rf, "_xml", {}) + xml_name = prop_meta.get("name", rf._rest_name) + xml_ns = _resolve_xml_ns(prop_meta, model_meta) + if xml_ns: + xml_name = "{" + xml_ns + "}" + xml_name + + # attribute + if prop_meta.get("attribute", False) and element.get(xml_name) is not None: + existed_attr_keys.append(xml_name) + result[rf._rest_name] = _deserialize(rf._type, element.get(xml_name)) + continue + + # unwrapped element is array + if prop_meta.get("unwrapped", False): + _items_name = prop_meta.get("itemsName") + if _items_name: + xml_name = _items_name + _items_ns = prop_meta.get("itemsNs") + if _items_ns is not None: + xml_ns = _items_ns + if xml_ns: + xml_name = "{" + xml_ns + "}" + xml_name + items = element.findall(xml_name) # pyright: ignore + if len(items) > 0: + existed_attr_keys.append(xml_name) + result[rf._rest_name] = _deserialize(rf._type, items) + elif not rf._is_optional: + existed_attr_keys.append(xml_name) + result[rf._rest_name] = [] + continue + + # text element is primitive type + if prop_meta.get("text", False): + if element.text is not None: + result[rf._rest_name] = _deserialize(rf._type, element.text) + continue + + # wrapped element could be normal property or array + item = element.find(xml_name) + if item is not None: + existed_attr_keys.append(xml_name) + result[rf._rest_name] = _deserialize(rf._type, item) + + # rest thing is additional properties + for e in element: + if e.tag not in existed_attr_keys: + result[e.tag] = _convert_element(e) + + return result + def copy(self) -> "Model": return Model(self.__dict__) @@ -688,6 +1038,9 @@ def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> Self: if not rf._rest_name_input: rf._rest_name_input = attr cls._attr_to_rest_field: dict[str, _RestField] = dict(attr_to_rest_field.items()) + # Build XML field plan for fast _init_from_xml (only for XML models) + if getattr(cls, "_xml", None): + cls._xml_field_plan = _build_xml_field_plan(cls, attr_to_rest_field) cls._calculated.add(f"{cls.__module__}.{cls.__qualname__}") return super().__new__(cls) @@ -716,7 +1069,7 @@ def _deserialize(cls, data, exist_discriminators): model_meta = getattr(cls, "_xml", {}) prop_meta = getattr(discriminator, "_xml", {}) xml_name = prop_meta.get("name", discriminator._rest_name) - xml_ns = prop_meta.get("ns", model_meta.get("ns", None)) + xml_ns = _resolve_xml_ns(prop_meta, model_meta) if xml_ns: xml_name = "{" + xml_ns + "}" + xml_name @@ -889,6 +1242,8 @@ def _get_deserialize_callable_from_annotation( # pylint: disable=too-many-retur # is it optional? try: if any(a is _NONE_TYPE for a in annotation.__args__): # pyright: ignore + if rf: + rf._is_optional = True if len(annotation.__args__) <= 2: # pyright: ignore if_obj_deserializer = _get_deserialize_callable_from_annotation( next(a for a in annotation.__args__ if a is not _NONE_TYPE), module, rf # pyright: ignore @@ -981,16 +1336,20 @@ def _deserialize_with_callable( return float(value.text) if value.text else None if deserializer is bool: return value.text == "true" if value.text else None + if deserializer and deserializer in _DESERIALIZE_MAPPING.values(): + return deserializer(value.text) if value.text else None + if deserializer and deserializer in _DESERIALIZE_MAPPING_WITHFORMAT.values(): + return deserializer(value.text) if value.text else None if deserializer is None: return value if deserializer in [int, float, bool]: return deserializer(value) if isinstance(deserializer, CaseInsensitiveEnumMeta): try: - return deserializer(value) + return deserializer(value.text if isinstance(value, ET.Element) else value) except ValueError: # for unknown value, return raw value - return value + return value.text if isinstance(value, ET.Element) else value if isinstance(deserializer, type) and issubclass(deserializer, Model): return deserializer._deserialize(value, []) return typing.cast(typing.Callable[[typing.Any], typing.Any], deserializer)(value) @@ -1043,6 +1402,7 @@ def _failsafe_deserialize_xml( return None +# pylint: disable=too-many-instance-attributes class _RestField: def __init__( self, @@ -1055,6 +1415,7 @@ def __init__( format: typing.Optional[str] = None, is_multipart_file_input: bool = False, xml: typing.Optional[dict[str, typing.Any]] = None, + deserializer: typing.Optional[typing.Callable] = None, ): self._type = type self._rest_name_input = name @@ -1062,10 +1423,12 @@ def __init__( self._is_discriminator = is_discriminator self._visibility = visibility self._is_model = False + self._is_optional = False self._default = default self._format = format self._is_multipart_file_input = is_multipart_file_input self._xml = xml if xml is not None else {} + self._deserializer = deserializer @property def _class_type(self) -> typing.Any: @@ -1085,7 +1448,10 @@ def __get__(self, obj: Model, type=None): # pylint: disable=redefined-builtin # by this point, type and rest_name will have a value bc we default # them in __new__ of the Model class # Use _data.get() directly to avoid triggering __getitem__ which clears the cache - item = obj._data.get(self._rest_name) + item = obj._data.get(self._rest_name, _UNSET) + if item is _UNSET: + # Field not set by user; return the client default if one exists, otherwise None + return self._default if self._default is not _UNSET else None if item is None: return item if self._is_model: @@ -1098,7 +1464,11 @@ def __get__(self, obj: Model, type=None): # pylint: disable=redefined-builtin # Return the value from _data directly (it's been deserialized in place) return obj._data.get(self._rest_name) - deserialized = _deserialize(self._type, _serialize(item, self._format), rf=self) + # Fast path: use _deserializer directly (avoids _serialize/_deserialize chain) + if self._deserializer: + deserialized = self._deserializer(item) + else: + deserialized = _deserialize(self._type, _serialize(item, self._format), rf=self) # For mutable types, store the deserialized value back in _data # so mutations directly affect _data @@ -1144,6 +1514,7 @@ def rest_field( format: typing.Optional[str] = None, is_multipart_file_input: bool = False, xml: typing.Optional[dict[str, typing.Any]] = None, + deserializer: typing.Optional[typing.Callable] = None, ) -> typing.Any: return _RestField( name=name, @@ -1153,6 +1524,7 @@ def rest_field( format=format, is_multipart_file_input=is_multipart_file_input, xml=xml, + deserializer=deserializer, ) @@ -1177,6 +1549,56 @@ def serialize_xml(model: Model, exclude_readonly: bool = False) -> str: return ET.tostring(_get_element(model, exclude_readonly), encoding="unicode") # type: ignore +def _get_xml_ns(meta: dict[str, typing.Any]) -> typing.Optional[str]: + """Return the XML namespace from a metadata dict, checking both 'ns' (old-style) and 'namespace' (DPG) keys. + + :param dict meta: The metadata dictionary to extract namespace from. + :returns: The namespace string if 'ns' or 'namespace' key is present, None otherwise. + :rtype: str or None + """ + ns = meta.get("ns") + if ns is None: + ns = meta.get("namespace") + return ns + + +def _resolve_xml_ns( + prop_meta: dict[str, typing.Any], model_meta: typing.Optional[dict[str, typing.Any]] = None +) -> typing.Optional[str]: + """Resolve XML namespace for a property, falling back to model namespace when appropriate. + + Checks the property metadata first; if no namespace is found and the model does not declare + an explicit prefix, falls back to the model-level namespace. + + :param dict prop_meta: The property metadata dictionary. + :param dict model_meta: The model metadata dictionary, used as fallback. + :returns: The resolved namespace string, or None. + :rtype: str or None + """ + ns = _get_xml_ns(prop_meta) + if ns is None and model_meta is not None and not model_meta.get("prefix"): + ns = _get_xml_ns(model_meta) + return ns + + +def _set_xml_attribute(element: ET.Element, name: str, value: typing.Any, prop_meta: dict[str, typing.Any]) -> None: + """Set an XML attribute on an element, handling namespace prefix registration. + + :param ET.Element element: The element to set the attribute on. + :param str name: The default attribute name (wire name). + :param any value: The attribute value. + :param dict prop_meta: The property metadata dictionary. + """ + xml_name = prop_meta.get("name", name) + _attr_ns = _get_xml_ns(prop_meta) + if _attr_ns: + _attr_prefix = prop_meta.get("prefix") + if _attr_prefix: + _safe_register_namespace(_attr_prefix, _attr_ns) + xml_name = "{" + _attr_ns + "}" + xml_name + element.set(xml_name, _get_primitive_type_value(value)) + + def _get_element( o: typing.Any, exclude_readonly: bool = False, @@ -1188,10 +1610,16 @@ def _get_element( # if prop is a model, then use the prop element directly, else generate a wrapper of model if wrapped_element is None: + # When serializing as an array item (parent_meta is set), check if the parent has an + # explicit itemsName. This ensures correct element names for unwrapped arrays (where + # the element tag is the property/items name, not the model type name). + _items_name = parent_meta.get("itemsName") if parent_meta is not None else None + element_name = _items_name if _items_name else (model_meta.get("name") or o.__class__.__name__) + _model_ns = _get_xml_ns(model_meta) wrapped_element = _create_xml_element( - model_meta.get("name", o.__class__.__name__), + element_name, model_meta.get("prefix"), - model_meta.get("ns"), + _model_ns, ) readonly_props = [] @@ -1213,7 +1641,9 @@ def _get_element( # additional properties will not have rest field, use the wire name as xml name prop_meta = {"name": k} - # if no ns for prop, use model's + # Propagate model namespace to properties only for old-style "ns"-keyed models. + # DPG-generated models use the "namespace" key and explicitly declare namespace on + # each property that needs it, so propagation is intentionally skipped for them. if prop_meta.get("ns") is None and model_meta.get("ns"): prop_meta["ns"] = model_meta.get("ns") prop_meta["prefix"] = model_meta.get("prefix") @@ -1225,12 +1655,7 @@ def _get_element( # text could only set on primitive type wrapped_element.text = _get_primitive_type_value(v) elif prop_meta.get("attribute", False): - xml_name = prop_meta.get("name", k) - if prop_meta.get("ns"): - ET.register_namespace(prop_meta.get("prefix"), prop_meta.get("ns")) # pyright: ignore - xml_name = "{" + prop_meta.get("ns") + "}" + xml_name # pyright: ignore - # attribute should be primitive type - wrapped_element.set(xml_name, _get_primitive_type_value(v)) + _set_xml_attribute(wrapped_element, k, v, prop_meta) else: # other wrapped prop element wrapped_element.append(_get_wrapped_element(v, exclude_readonly, prop_meta)) @@ -1239,6 +1664,7 @@ def _get_element( return [_get_element(x, exclude_readonly, parent_meta) for x in o] # type: ignore if isinstance(o, dict): result = [] + _dict_ns = _get_xml_ns(parent_meta) if parent_meta else None for k, v in o.items(): result.append( _get_wrapped_element( @@ -1246,7 +1672,7 @@ def _get_element( exclude_readonly, { "name": k, - "ns": parent_meta.get("ns") if parent_meta else None, + "ns": _dict_ns, "prefix": parent_meta.get("prefix") if parent_meta else None, }, ) @@ -1255,13 +1681,16 @@ def _get_element( # primitive case need to create element based on parent_meta if parent_meta: + _items_ns = parent_meta.get("itemsNs") + if _items_ns is None: + _items_ns = _get_xml_ns(parent_meta) return _get_wrapped_element( o, exclude_readonly, { "name": parent_meta.get("itemsName", parent_meta.get("name")), "prefix": parent_meta.get("itemsPrefix", parent_meta.get("prefix")), - "ns": parent_meta.get("itemsNs", parent_meta.get("ns")), + "ns": _items_ns, }, ) @@ -1273,8 +1702,9 @@ def _get_wrapped_element( exclude_readonly: bool, meta: typing.Optional[dict[str, typing.Any]], ) -> ET.Element: + _meta_ns = _get_xml_ns(meta) if meta else None wrapped_element = _create_xml_element( - meta.get("name") if meta else None, meta.get("prefix") if meta else None, meta.get("ns") if meta else None + meta.get("name") if meta else None, meta.get("prefix") if meta else None, _meta_ns ) if isinstance(v, (dict, list)): wrapped_element.extend(_get_element(v, exclude_readonly, meta)) @@ -1295,11 +1725,29 @@ def _get_primitive_type_value(v) -> str: return str(v) +def _safe_register_namespace(prefix: str, ns: str) -> None: + """Register an XML namespace prefix, handling reserved prefix patterns. + + Some prefixes (e.g. 'ns2') match Python's reserved 'ns\\d+' pattern used for + auto-generated prefixes, causing register_namespace to raise ValueError. + Falls back to directly registering in the internal namespace map. + + :param str prefix: The namespace prefix to register. + :param str ns: The namespace URI. + """ + try: + ET.register_namespace(prefix, ns) + except ValueError: + _ns_map = getattr(ET, "_namespace_map", None) + if _ns_map is not None: + _ns_map[ns] = prefix + + def _create_xml_element( tag: typing.Any, prefix: typing.Optional[str] = None, ns: typing.Optional[str] = None ) -> ET.Element: if prefix and ns: - ET.register_namespace(prefix, ns) + _safe_register_namespace(prefix, ns) if ns: return ET.Element("{" + ns + "}" + tag) return ET.Element(tag) @@ -1310,6 +1758,8 @@ def _deserialize_xml( value: str, ) -> typing.Any: element = ET.fromstring(value) # nosec + if _is_model(deserializer): + return deserializer._deserialize(element, []) return _deserialize(deserializer, element) diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_utils/serialization.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_utils/serialization.py index 81ec1de5922..ae08f9d89f7 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_utils/serialization.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_utils/serialization.py @@ -39,11 +39,15 @@ import xml.etree.ElementTree as ET import isodate # type: ignore -from typing_extensions import Self from azure.core.exceptions import DeserializationError, SerializationError from azure.core.serialization import NULL as CoreNull +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + _BOM = codecs.BOM_UTF8.decode(encoding="utf-8") JSON = MutableMapping[str, Any] @@ -476,7 +480,11 @@ def _decode_attribute_map_key(key): class Serializer: # pylint: disable=too-many-public-methods - """Request object model serializer.""" + """Request object model serializer. + + :param classes: Mapping of model names to model types, used to resolve models during serialization. + :type classes: typing.Optional[typing.Mapping[str, type]] + """ basic_types = {str: "str", int: "int", bool: "bool", float: "float"} @@ -516,6 +524,10 @@ def __init__(self, classes: Optional[Mapping[str, type]] = None) -> None: "rfc-1123": Serializer.serialize_rfc, "unix-time": Serializer.serialize_unix, "duration": Serializer.serialize_duration, + "duration-seconds-int": Serializer.serialize_duration_seconds_int, + "duration-seconds-float": Serializer.serialize_duration_seconds_float, + "duration-milliseconds-int": Serializer.serialize_duration_milliseconds_int, + "duration-milliseconds-float": Serializer.serialize_duration_milliseconds_float, "date": Serializer.serialize_date, "time": Serializer.serialize_time, "decimal": Serializer.serialize_decimal, @@ -1105,6 +1117,61 @@ def serialize_duration(attr, **kwargs): # pylint: disable=unused-argument attr = isodate.parse_duration(attr) return isodate.duration_isoformat(attr) + @staticmethod + def _serialize_duration_numeric(attr, scale, as_int): + """Serialize a TimeDelta into a numeric value scaled to the wire unit. + + :param TimeDelta attr: Object to be serialized. + :param int scale: Multiplier applied to total seconds (1 for seconds, 1000 for milliseconds). + :param bool as_int: Whether to truncate the result to an int. + :rtype: int or float + :return: serialized duration + """ + if isinstance(attr, str): + attr = isodate.parse_duration(attr) + value = attr.total_seconds() * scale if isinstance(attr, datetime.timedelta) else attr + return int(value) if as_int else float(value) + + @staticmethod + def serialize_duration_seconds_int(attr, **kwargs): # pylint: disable=unused-argument + """Serialize TimeDelta object into an integer number of seconds. + + :param TimeDelta attr: Object to be serialized. + :rtype: int + :return: serialized duration + """ + return Serializer._serialize_duration_numeric(attr, 1, True) + + @staticmethod + def serialize_duration_seconds_float(attr, **kwargs): # pylint: disable=unused-argument + """Serialize TimeDelta object into a floating point number of seconds. + + :param TimeDelta attr: Object to be serialized. + :rtype: float + :return: serialized duration + """ + return Serializer._serialize_duration_numeric(attr, 1, False) + + @staticmethod + def serialize_duration_milliseconds_int(attr, **kwargs): # pylint: disable=unused-argument + """Serialize TimeDelta object into an integer number of milliseconds. + + :param TimeDelta attr: Object to be serialized. + :rtype: int + :return: serialized duration + """ + return Serializer._serialize_duration_numeric(attr, 1000, True) + + @staticmethod + def serialize_duration_milliseconds_float(attr, **kwargs): # pylint: disable=unused-argument + """Serialize TimeDelta object into a floating point number of milliseconds. + + :param TimeDelta attr: Object to be serialized. + :rtype: float + :return: serialized duration + """ + return Serializer._serialize_duration_numeric(attr, 1000, False) + @staticmethod def serialize_rfc(attr, **kwargs): # pylint: disable=unused-argument """Serialize Datetime object into RFC-1123 formatted string. @@ -1377,6 +1444,10 @@ def __init__(self, classes: Optional[Mapping[str, type]] = None) -> None: "rfc-1123": Deserializer.deserialize_rfc, "unix-time": Deserializer.deserialize_unix, "duration": Deserializer.deserialize_duration, + "duration-seconds-int": Deserializer.deserialize_duration_seconds, + "duration-seconds-float": Deserializer.deserialize_duration_seconds, + "duration-milliseconds-int": Deserializer.deserialize_duration_milliseconds, + "duration-milliseconds-float": Deserializer.deserialize_duration_milliseconds, "date": Deserializer.deserialize_date, "time": Deserializer.deserialize_time, "decimal": Deserializer.deserialize_decimal, @@ -1389,6 +1460,10 @@ def __init__(self, classes: Optional[Mapping[str, type]] = None) -> None: } self.deserialize_expected_types = { "duration": (isodate.Duration, datetime.timedelta), + "duration-seconds-int": (isodate.Duration, datetime.timedelta), + "duration-seconds-float": (isodate.Duration, datetime.timedelta), + "duration-milliseconds-int": (isodate.Duration, datetime.timedelta), + "duration-milliseconds-float": (isodate.Duration, datetime.timedelta), "iso-8601": (datetime.datetime), } self.dependencies: dict[str, type] = dict(classes) if classes else {} @@ -1401,7 +1476,7 @@ def __init__(self, classes: Optional[Mapping[str, type]] = None) -> None: # Otherwise, result are unexpected self.additional_properties_detection = True - def __call__(self, target_obj, response_data, content_type=None): + def __call__(self, target_obj, response_data, content_type=None): # pylint: disable=too-many-return-statements """Call the deserializer to process a REST response. :param str target_obj: Target data type to deserialize to. @@ -1411,6 +1486,27 @@ def __call__(self, target_obj, response_data, content_type=None): :return: Deserialized object. :rtype: object """ + # Fast path for header deserialization: response_data is a plain str or None + # and target_obj is a simple scalar type. This avoids the expensive + # _unpack_content → _deserialize → _classify_target → deserialize_data chain. + if response_data is None: + return None + if target_obj == "str" and isinstance(response_data, str): + return response_data + if isinstance(response_data, str): + if target_obj == "int": + return int(response_data) + if target_obj == "bool": + if response_data in ("true", "1", "True"): + return True + if response_data in ("false", "0", "False"): + return False + return bool(response_data) + if target_obj == "rfc-1123": + return Deserializer.deserialize_rfc(response_data) + if target_obj == "bytearray": + return Deserializer.deserialize_bytearray(response_data) + data = self._unpack_content(response_data, content_type) return self._deserialize(target_obj, data) @@ -1929,6 +2025,48 @@ def deserialize_duration(attr): raise DeserializationError(msg) from err return duration + @staticmethod + def _deserialize_duration_numeric(attr, unit): + """Deserialize a numeric duration value into a TimeDelta object. + + :param float attr: response value to be deserialized. + :param str unit: The wire unit, used as the ``timedelta`` keyword + (``"seconds"`` or ``"milliseconds"``). + :return: Deserialized duration + :rtype: TimeDelta + :raises DeserializationError: if value is invalid. + """ + if isinstance(attr, ET.Element): + attr = attr.text + try: + duration = datetime.timedelta(**{unit: float(attr)}) # type: ignore + except (ValueError, OverflowError, TypeError) as err: + msg = "Cannot deserialize duration object." + raise DeserializationError(msg) from err + return duration + + @staticmethod + def deserialize_duration_seconds(attr): + """Deserialize a numeric number of seconds into a TimeDelta object. + + :param float attr: response value to be deserialized. + :return: Deserialized duration + :rtype: TimeDelta + :raises DeserializationError: if value is invalid. + """ + return Deserializer._deserialize_duration_numeric(attr, "seconds") + + @staticmethod + def deserialize_duration_milliseconds(attr): + """Deserialize a numeric number of milliseconds into a TimeDelta object. + + :param float attr: response value to be deserialized. + :return: Deserialized duration + :rtype: TimeDelta + :raises DeserializationError: if value is invalid. + """ + return Deserializer._deserialize_duration_numeric(attr, "milliseconds") + @staticmethod def deserialize_date(attr): """Deserialize ISO-8601 formatted string into Date object. diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_version.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_version.py index 6488fea5c70..180ccd2777a 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_version.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/_version.py @@ -2,7 +2,7 @@ # -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. -# Code generated by Microsoft (R) AutoRest Code Generator. +# Code generated by Microsoft (R) Python Code Generator. # Changes may cause incorrect behavior and will be lost if the code is regenerated. # -------------------------------------------------------------------------- diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_client.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_client.py index 21f48db15e7..32b01953653 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_client.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_client.py @@ -7,8 +7,8 @@ # -------------------------------------------------------------------------- from copy import deepcopy +import sys from typing import Any, Awaitable, TYPE_CHECKING, Union -from typing_extensions import Self from azure.core import AsyncPipelineClient from azure.core.credentials import AzureKeyCredential @@ -19,11 +19,16 @@ from ._configuration import WorkspaceClientConfiguration from .operations import ServicesOperations +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self # type: ignore + if TYPE_CHECKING: from azure.core.credentials_async import AsyncTokenCredential -class WorkspaceClient: +class WorkspaceClient: # pylint: disable=docstring-keyword-should-match-keyword-only """Azure Quantum Workspace Services. :ivar services: ServicesOperations operations @@ -36,8 +41,9 @@ class WorkspaceClient: :type credential: ~azure.core.credentials_async.AsyncTokenCredential or ~azure.core.credentials.AzureKeyCredential :keyword api_version: The API version to use for this operation. Known values are - "2026-01-15-preview" and None. Default value is "2026-01-15-preview". Note that overriding this - default value may result in unsupported behavior. + "2026-01-15-preview" and None. Default value is None. If not set, the operation's default API + version will be used. Note that overriding this default value may result in unsupported + behavior. :paramtype api_version: str """ diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_configuration.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_configuration.py index 5d1aff54c11..5109ff7e4c7 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_configuration.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_configuration.py @@ -1,3 +1,4 @@ +# pylint: disable=line-too-long,useless-suppression # coding=utf-8 # -------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. @@ -17,7 +18,7 @@ from azure.core.credentials_async import AsyncTokenCredential -class WorkspaceClientConfiguration: # pylint: disable=too-many-instance-attributes +class WorkspaceClientConfiguration: # pylint: disable=too-many-instance-attributes,docstring-keyword-should-match-keyword-only """Configuration for WorkspaceClient. Note that all parameters used to create this instance are saved as instance @@ -31,8 +32,9 @@ class WorkspaceClientConfiguration: # pylint: disable=too-many-instance-attribu :type credential: ~azure.core.credentials_async.AsyncTokenCredential or ~azure.core.credentials.AzureKeyCredential :keyword api_version: The API version to use for this operation. Known values are - "2026-01-15-preview" and None. Default value is "2026-01-15-preview". Note that overriding this - default value may result in unsupported behavior. + "2026-01-15-preview" and None. Default value is None. If not set, the operation's default API + version will be used. Note that overriding this default value may result in unsupported + behavior. :paramtype api_version: str """ diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_patch.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_patch.py index 87676c65a8f..ea765788358 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_patch.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/_patch.py @@ -8,7 +8,6 @@ Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize """ - __all__: list[str] = [] # Add all objects you want publicly available to users at this package level diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py index ba9ba2a3c95..e4cc9c5a748 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py @@ -30,7 +30,7 @@ from azure.core.tracing.decorator_async import distributed_trace_async from azure.core.utils import case_insensitive_dict -from ... import models as _models +from ... import models as _models, types as _types from ..._utils.model_base import SdkJSONEncoder, _deserialize from ..._utils.serialization import Deserializer, Serializer from ..._validation import api_version_validation @@ -43,7 +43,7 @@ build_services_jobs_update_request, build_services_providers_list_request, build_services_quotas_list_request, - build_services_quotas_list_quota_usages_request, + build_services_quotas_list_workspace_usages_request, build_services_sessions_close_request, build_services_sessions_get_request, build_services_sessions_jobs_list_request, @@ -58,10 +58,9 @@ T = TypeVar("T") ClsType = Optional[Callable[[PipelineResponse[HttpRequest, AsyncHttpResponse], T, dict[str, Any]], Any]] -JSON = MutableMapping[str, Any] -class ServicesOperations: +class ServicesOperations: # pylint: disable=docstring-missing-param,too-many-instance-attributes """ .. warning:: **DO NOT** instantiate this class directly. @@ -84,14 +83,14 @@ def __init__(self, *args, **kwargs) -> None: self.jobs = ServicesJobsOperations(self._client, self._config, self._serialize, self._deserialize) self.providers = ServicesProvidersOperations(self._client, self._config, self._serialize, self._deserialize) self.quotas = ServicesQuotasOperations(self._client, self._config, self._serialize, self._deserialize) + self.sessions = ServicesSessionsOperations(self._client, self._config, self._serialize, self._deserialize) + self.storage = ServicesStorageOperations(self._client, self._config, self._serialize, self._deserialize) self.suite_offers = ServicesSuiteOffersOperations( self._client, self._config, self._serialize, self._deserialize ) - self.sessions = ServicesSessionsOperations(self._client, self._config, self._serialize, self._deserialize) - self.storage = ServicesStorageOperations(self._client, self._config, self._serialize, self._deserialize) -class ServicesTopLevelItemsOperations: +class ServicesTopLevelItemsOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -204,7 +203,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -217,7 +219,10 @@ def prepare_request(next_link=None): async def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.ItemDetails], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.ItemDetails], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, AsyncList(list_of_elem) @@ -240,7 +245,7 @@ async def get_next(next_link=None): return AsyncItemPaged(get_next, extract_data) -class ServicesJobsOperations: +class ServicesJobsOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -296,7 +301,7 @@ async def create( resource_group_name: str, workspace_name: str, job_id: str, - resource: JSON, + resource: _types.JobDetails, *, content_type: str = "application/json", **kwargs: Any @@ -312,7 +317,7 @@ async def create( :param job_id: Id of the job. Required. :type job_id: str :param resource: The resource instance. Required. - :type resource: JSON + :type resource: ~azure.quantum.types.JobDetails :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. Default value is "application/json". :paramtype content_type: str @@ -360,7 +365,7 @@ async def create( resource_group_name: str, workspace_name: str, job_id: str, - resource: Union[_models.JobDetails, JSON, IO[bytes]], + resource: Union[_models.JobDetails, _types.JobDetails, IO[bytes]], **kwargs: Any ) -> _models.JobDetails: """Create a new job. @@ -373,9 +378,10 @@ async def create( :type workspace_name: str :param job_id: Id of the job. Required. :type job_id: str - :param resource: The resource instance. Is one of the following types: JobDetails, JSON, - IO[bytes] Required. - :type resource: ~azure.quantum.models.JobDetails or JSON or IO[bytes] + :param resource: The resource instance. Is either a JobDetails type or a IO[bytes] type. + Required. + :type resource: ~azure.quantum.models.JobDetails or ~azure.quantum.types.JobDetails or + IO[bytes] :return: JobDetails. The JobDetails is compatible with MutableMapping :rtype: ~azure.quantum.models.JobDetails :raises ~azure.core.exceptions.HttpResponseError: @@ -417,6 +423,7 @@ async def create( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -434,7 +441,7 @@ async def create( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.JobDetails, response.json()) @@ -454,7 +461,7 @@ async def update( *, content_type: str = "application/merge-patch+json", **kwargs: Any - ) -> _models.JobUpdateOptions: + ) -> _models.JobUpdateResponse: """Update job properties. :param subscription_id: The Azure subscription ID. Required. @@ -470,8 +477,8 @@ async def update( :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. Default value is "application/merge-patch+json". :paramtype content_type: str - :return: JobUpdateOptions. The JobUpdateOptions is compatible with MutableMapping - :rtype: ~azure.quantum.models.JobUpdateOptions + :return: JobUpdateResponse. The JobUpdateResponse is compatible with MutableMapping + :rtype: ~azure.quantum.models.JobUpdateResponse :raises ~azure.core.exceptions.HttpResponseError: """ @@ -482,11 +489,11 @@ async def update( resource_group_name: str, workspace_name: str, job_id: str, - resource: JSON, + resource: _types.JobUpdateOptions, *, content_type: str = "application/merge-patch+json", **kwargs: Any - ) -> _models.JobUpdateOptions: + ) -> _models.JobUpdateResponse: """Update job properties. :param subscription_id: The Azure subscription ID. Required. @@ -498,12 +505,12 @@ async def update( :param job_id: Id of the job. Required. :type job_id: str :param resource: The resource instance. Required. - :type resource: JSON + :type resource: ~azure.quantum.types.JobUpdateOptions :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. Default value is "application/merge-patch+json". :paramtype content_type: str - :return: JobUpdateOptions. The JobUpdateOptions is compatible with MutableMapping - :rtype: ~azure.quantum.models.JobUpdateOptions + :return: JobUpdateResponse. The JobUpdateResponse is compatible with MutableMapping + :rtype: ~azure.quantum.models.JobUpdateResponse :raises ~azure.core.exceptions.HttpResponseError: """ @@ -518,7 +525,7 @@ async def update( *, content_type: str = "application/merge-patch+json", **kwargs: Any - ) -> _models.JobUpdateOptions: + ) -> _models.JobUpdateResponse: """Update job properties. :param subscription_id: The Azure subscription ID. Required. @@ -534,8 +541,8 @@ async def update( :keyword content_type: Body Parameter content-type. Content type parameter for binary body. Default value is "application/merge-patch+json". :paramtype content_type: str - :return: JobUpdateOptions. The JobUpdateOptions is compatible with MutableMapping - :rtype: ~azure.quantum.models.JobUpdateOptions + :return: JobUpdateResponse. The JobUpdateResponse is compatible with MutableMapping + :rtype: ~azure.quantum.models.JobUpdateResponse :raises ~azure.core.exceptions.HttpResponseError: """ @@ -561,9 +568,9 @@ async def update( resource_group_name: str, workspace_name: str, job_id: str, - resource: Union[_models.JobUpdateOptions, JSON, IO[bytes]], + resource: Union[_models.JobUpdateOptions, _types.JobUpdateOptions, IO[bytes]], **kwargs: Any - ) -> _models.JobUpdateOptions: + ) -> _models.JobUpdateResponse: """Update job properties. :param subscription_id: The Azure subscription ID. Required. @@ -574,11 +581,12 @@ async def update( :type workspace_name: str :param job_id: Id of the job. Required. :type job_id: str - :param resource: The resource instance. Is one of the following types: JobUpdateOptions, JSON, - IO[bytes] Required. - :type resource: ~azure.quantum.models.JobUpdateOptions or JSON or IO[bytes] - :return: JobUpdateOptions. The JobUpdateOptions is compatible with MutableMapping - :rtype: ~azure.quantum.models.JobUpdateOptions + :param resource: The resource instance. Is either a JobUpdateOptions type or a IO[bytes] type. + Required. + :type resource: ~azure.quantum.models.JobUpdateOptions or ~azure.quantum.types.JobUpdateOptions + or IO[bytes] + :return: JobUpdateResponse. The JobUpdateResponse is compatible with MutableMapping + :rtype: ~azure.quantum.models.JobUpdateResponse :raises ~azure.core.exceptions.HttpResponseError: """ error_map: MutableMapping = { @@ -593,7 +601,7 @@ async def update( _params = kwargs.pop("params", {}) or {} content_type: Optional[str] = kwargs.pop("content_type", _headers.pop("Content-Type", None)) - cls: ClsType[_models.JobUpdateOptions] = kwargs.pop("cls", None) + cls: ClsType[_models.JobUpdateResponse] = kwargs.pop("cls", None) content_type = content_type or "application/merge-patch+json" _content = None @@ -618,6 +626,7 @@ async def update( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -635,9 +644,9 @@ async def update( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: - deserialized = _deserialize(_models.JobUpdateOptions, response.json()) + deserialized = _deserialize(_models.JobUpdateResponse, response.json()) if cls: return cls(pipeline_response, deserialized, {}) # type: ignore @@ -762,6 +771,7 @@ async def cancel( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -779,7 +789,7 @@ async def cancel( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.JobDetails, response.json()) @@ -833,6 +843,7 @@ async def get( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -850,7 +861,7 @@ async def get( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.JobDetails, response.json()) @@ -948,7 +959,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -961,7 +975,10 @@ def prepare_request(next_link=None): async def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.JobDetails], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.JobDetails], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, AsyncList(list_of_elem) @@ -984,7 +1001,7 @@ async def get_next(next_link=None): return AsyncItemPaged(get_next, extract_data) -class ServicesProvidersOperations: +class ServicesProvidersOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -1059,7 +1076,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -1072,7 +1092,10 @@ def prepare_request(next_link=None): async def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.ProviderStatus], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.ProviderStatus], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, AsyncList(list_of_elem) @@ -1095,7 +1118,7 @@ async def get_next(next_link=None): return AsyncItemPaged(get_next, extract_data) -class ServicesQuotasOperations: +class ServicesQuotasOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -1170,7 +1193,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -1183,7 +1209,10 @@ def prepare_request(next_link=None): async def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.Quota], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.Quota], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, AsyncList(list_of_elem) @@ -1205,11 +1234,25 @@ async def get_next(next_link=None): return AsyncItemPaged(get_next, extract_data) - @distributed_trace_async - async def list_quota_usages( - self, subscription_id: str, resource_group_name: str, workspace_name: str, provider_id: str, **kwargs: Any - ) -> "list[_models.QuotaUsage]": - """List quota usages for a provider in the given workspace. + @distributed_trace + @api_version_validation( + method_added_on="2026-01-15-preview", + params_added_on={ + "2026-01-15-preview": [ + "api_version", + "subscription_id", + "resource_group_name", + "workspace_name", + "provider_id", + "accept", + ] + }, + api_versions_list=["2026-01-15-preview"], + ) + def list_workspace_usages( + self, subscription_id: str, resource_group_name: str, workspace_name: str, *, provider_id: str, **kwargs: Any + ) -> AsyncItemPaged["_models.QuotaUsageData"]: + """List quota usages for the given workspace. This operation is only available for v2 workspaces. :param subscription_id: The Azure subscription ID. Required. :type subscription_id: str @@ -1217,16 +1260,16 @@ async def list_quota_usages( :type resource_group_name: str :param workspace_name: Name of the Azure Quantum workspace. Required. :type workspace_name: str - :param provider_id: The provider whose quota usages are requested. Required. - :type provider_id: str - :return: list of QuotaUsage - :rtype: list[~azure.quantum.models.QuotaUsage] + :keyword provider_id: The unique identifier for the provider to get quota usages for. Required. + :paramtype provider_id: str + :return: An iterator like instance of QuotaUsageData + :rtype: ~azure.core.async_paging.AsyncItemPaged[~azure.quantum.models.QuotaUsageData] :raises ~azure.core.exceptions.HttpResponseError: """ _headers = kwargs.pop("headers", {}) or {} _params = kwargs.pop("params", {}) or {} - cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) + cls: ClsType[list[_models.QuotaUsageData]] = kwargs.pop("cls", None) error_map: MutableMapping = { 401: ClientAuthenticationError, @@ -1236,178 +1279,79 @@ async def list_quota_usages( } error_map.update(kwargs.pop("error_map", {}) or {}) - _request = build_services_quotas_list_quota_usages_request( - subscription_id=subscription_id, - resource_group_name=resource_group_name, - workspace_name=workspace_name, - provider_id=provider_id, - api_version=self._config.api_version, - headers=_headers, - params=_params, - ) - path_format_arguments = { - "endpoint": self._serialize.url( - "self._config.endpoint", self._config.endpoint, "str", skip_quote=True - ), - } - _request.url = self._client.format_url(_request.url, **path_format_arguments) - - _stream = False - pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access - _request, stream=_stream, **kwargs - ) - response = pipeline_response.http_response - - if response.status_code not in [200]: - map_error(status_code=response.status_code, response=response, error_map=error_map) - raise HttpResponseError(response=response) - - deserialized = response.json() - # The service may return either a bare JSON array or a paged envelope ({"value": [...]}). - if isinstance(deserialized, dict): - deserialized = deserialized.get("value", []) - list_of_elem = _deserialize(list[_models.QuotaUsage], deserialized) - if cls: - return cls(list_of_elem) # type: ignore - return list_of_elem - - -class ServicesSuiteOffersOperations: - """ - .. warning:: - **DO NOT** instantiate this class directly. - - Instead, you should access the following operations through - :class:`~azure.quantum.aio.WorkspaceClient`'s - :attr:`suite_offers` attribute. - """ - - def __init__(self, *args, **kwargs) -> None: - input_args = list(args) - self._client: AsyncPipelineClient = input_args.pop(0) if input_args else kwargs.pop("client") - self._config: WorkspaceClientConfiguration = input_args.pop(0) if input_args else kwargs.pop("config") - self._serialize: Serializer = input_args.pop(0) if input_args else kwargs.pop("serializer") - self._deserialize: Deserializer = input_args.pop(0) if input_args else kwargs.pop("deserializer") - - @distributed_trace_async - async def list_quota_usages( - self, subscription_id: str, provider_id: str, **kwargs: Any - ) -> list["_models.QuotaUsage"]: - """List quota usages for the given suite offer provider account. - - :param subscription_id: The Azure subscription ID. Required. - :type subscription_id: str - :param provider_id: The unique identifier of the suite offer provider account. Required. - :type provider_id: str - :return: list of QuotaUsage - :rtype: list[~azure.quantum.models.QuotaUsage] - :raises ~azure.core.exceptions.HttpResponseError: - """ - _headers = kwargs.pop("headers", {}) or {} - _params = kwargs.pop("params", {}) or {} - - cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) - - error_map: MutableMapping = { - 401: ClientAuthenticationError, - 404: ResourceNotFoundError, - 409: ResourceExistsError, - 304: ResourceNotModifiedError, - } - error_map.update(kwargs.pop("error_map", {}) or {}) - - _request = build_services_suite_offers_list_quota_usages_request( - subscription_id=subscription_id, - provider_id=provider_id, - api_version=self._config.api_version, - headers=_headers, - params=_params, - ) - path_format_arguments = { - "endpoint": self._serialize.url( - "self._config.endpoint", self._config.endpoint, "str", skip_quote=True - ), - } - _request.url = self._client.format_url(_request.url, **path_format_arguments) - - _stream = False - pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access - _request, stream=_stream, **kwargs - ) - response = pipeline_response.http_response - - if response.status_code not in [200]: - map_error(status_code=response.status_code, response=response, error_map=error_map) - raise HttpResponseError(response=response) + def prepare_request(next_link=None): + if not next_link: - deserialized = response.json() - # The service may return either a bare JSON array or a paged envelope ({"value": [...]}). - if isinstance(deserialized, dict): - deserialized = deserialized.get("value", []) - list_of_elem = _deserialize(list[_models.QuotaUsage], deserialized) - if cls: - return cls(list_of_elem) # type: ignore - return list_of_elem + _request = build_services_quotas_list_workspace_usages_request( + subscription_id=subscription_id, + resource_group_name=resource_group_name, + workspace_name=workspace_name, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) - @distributed_trace_async - async def get_provider_status( - self, subscription_id: str, provider_id: str, **kwargs: Any - ) -> "_models.ProviderStatus": - """Get the target status for the given suite offer provider account. + else: + # make call to next link with the client's api-version + _parsed_next_link = urllib.parse.urlparse(next_link) + _next_request_params = case_insensitive_dict( + { + key: [urllib.parse.quote(v) for v in value] + for key, value in urllib.parse.parse_qs(_parsed_next_link.query).items() + } + ) + _next_request_params["api-version"] = self._config.api_version + _request = HttpRequest( + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) - :param subscription_id: The Azure subscription ID. Required. - :type subscription_id: str - :param provider_id: The unique identifier of the suite offer provider account. Required. - :type provider_id: str - :return: ProviderStatus - :rtype: ~azure.quantum.models.ProviderStatus - :raises ~azure.core.exceptions.HttpResponseError: - """ - _headers = kwargs.pop("headers", {}) or {} - _params = kwargs.pop("params", {}) or {} + return _request - cls: ClsType[_models.ProviderStatus] = kwargs.pop("cls", None) + async def extract_data(pipeline_response): + deserialized = pipeline_response.http_response.json() + list_of_elem = _deserialize( + list[_models.QuotaUsageData], + deserialized.get("value", []), + ) + if cls: + list_of_elem = cls(list_of_elem) # type: ignore + return deserialized.get("nextLink") or None, AsyncList(list_of_elem) - error_map: MutableMapping = { - 401: ClientAuthenticationError, - 404: ResourceNotFoundError, - 409: ResourceExistsError, - 304: ResourceNotModifiedError, - } - error_map.update(kwargs.pop("error_map", {}) or {}) + async def get_next(next_link=None): + _request = prepare_request(next_link) - _request = build_services_suite_offers_get_provider_status_request( - subscription_id=subscription_id, - provider_id=provider_id, - api_version=self._config.api_version, - headers=_headers, - params=_params, - ) - path_format_arguments = { - "endpoint": self._serialize.url( - "self._config.endpoint", self._config.endpoint, "str", skip_quote=True - ), - } - _request.url = self._client.format_url(_request.url, **path_format_arguments) + _stream = False + pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + response = pipeline_response.http_response - _stream = False - pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access - _request, stream=_stream, **kwargs - ) - response = pipeline_response.http_response + if response.status_code not in [200]: + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) - if response.status_code not in [200]: - map_error(status_code=response.status_code, response=response, error_map=error_map) - raise HttpResponseError(response=response) + return pipeline_response - # The provider status endpoint returns a single ProviderStatus object. - deserialized = _deserialize(_models.ProviderStatus, response.json()) - if cls: - return cls(deserialized) # type: ignore - return deserialized + return AsyncItemPaged(get_next, extract_data) -class ServicesSessionsOperations: +class ServicesSessionsOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -1463,7 +1407,7 @@ async def open( resource_group_name: str, workspace_name: str, session_id: str, - resource: JSON, + resource: _types.SessionDetails, *, content_type: str = "application/json", **kwargs: Any @@ -1479,7 +1423,7 @@ async def open( :param session_id: Id of the session. Required. :type session_id: str :param resource: The resource instance. Required. - :type resource: JSON + :type resource: ~azure.quantum.types.SessionDetails :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. Default value is "application/json". :paramtype content_type: str @@ -1527,7 +1471,7 @@ async def open( resource_group_name: str, workspace_name: str, session_id: str, - resource: Union[_models.SessionDetails, JSON, IO[bytes]], + resource: Union[_models.SessionDetails, _types.SessionDetails, IO[bytes]], **kwargs: Any ) -> _models.SessionDetails: """Open a new session. @@ -1540,9 +1484,10 @@ async def open( :type workspace_name: str :param session_id: Id of the session. Required. :type session_id: str - :param resource: The resource instance. Is one of the following types: SessionDetails, JSON, - IO[bytes] Required. - :type resource: ~azure.quantum.models.SessionDetails or JSON or IO[bytes] + :param resource: The resource instance. Is either a SessionDetails type or a IO[bytes] type. + Required. + :type resource: ~azure.quantum.models.SessionDetails or ~azure.quantum.types.SessionDetails or + IO[bytes] :return: SessionDetails. The SessionDetails is compatible with MutableMapping :rtype: ~azure.quantum.models.SessionDetails :raises ~azure.core.exceptions.HttpResponseError: @@ -1584,6 +1529,7 @@ async def open( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -1601,7 +1547,7 @@ async def open( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.SessionDetails, response.json()) @@ -1655,6 +1601,7 @@ async def close( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -1672,7 +1619,7 @@ async def close( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.SessionDetails, response.json()) @@ -1726,6 +1673,7 @@ async def get( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -1743,7 +1691,7 @@ async def get( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.SessionDetails, response.json()) @@ -1848,7 +1796,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -1861,7 +1812,10 @@ def prepare_request(next_link=None): async def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.SessionDetails], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.SessionDetails], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, AsyncList(list_of_elem) @@ -1976,7 +1930,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -1989,7 +1946,10 @@ def prepare_request(next_link=None): async def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.JobDetails], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.JobDetails], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, AsyncList(list_of_elem) @@ -2012,7 +1972,7 @@ async def get_next(next_link=None): return AsyncItemPaged(get_next, extract_data) -class ServicesStorageOperations: +class ServicesStorageOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -2067,7 +2027,7 @@ async def get_sas_uri( subscription_id: str, resource_group_name: str, workspace_name: str, - blob_details: JSON, + blob_details: _types.BlobDetails, *, content_type: str = "application/json", **kwargs: Any @@ -2084,7 +2044,7 @@ async def get_sas_uri( :param workspace_name: Name of the Azure Quantum workspace. Required. :type workspace_name: str :param blob_details: The details (name and container) of the blob. Required. - :type blob_details: JSON + :type blob_details: ~azure.quantum.types.BlobDetails :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. Default value is "application/json". :paramtype content_type: str @@ -2131,7 +2091,7 @@ async def get_sas_uri( subscription_id: str, resource_group_name: str, workspace_name: str, - blob_details: Union[_models.BlobDetails, JSON, IO[bytes]], + blob_details: Union[_models.BlobDetails, _types.BlobDetails, IO[bytes]], **kwargs: Any ) -> _models.SasUriResponse: """Gets a URL with SAS token for a container/blob in the storage account associated with the @@ -2145,9 +2105,10 @@ async def get_sas_uri( :type resource_group_name: str :param workspace_name: Name of the Azure Quantum workspace. Required. :type workspace_name: str - :param blob_details: The details (name and container) of the blob. Is one of the following - types: BlobDetails, JSON, IO[bytes] Required. - :type blob_details: ~azure.quantum.models.BlobDetails or JSON or IO[bytes] + :param blob_details: The details (name and container) of the blob. Is either a BlobDetails type + or a IO[bytes] type. Required. + :type blob_details: ~azure.quantum.models.BlobDetails or ~azure.quantum.types.BlobDetails or + IO[bytes] :return: SasUriResponse. The SasUriResponse is compatible with MutableMapping :rtype: ~azure.quantum.models.SasUriResponse :raises ~azure.core.exceptions.HttpResponseError: @@ -2188,6 +2149,7 @@ async def get_sas_uri( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -2205,7 +2167,7 @@ async def get_sas_uri( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.SasUriResponse, response.json()) @@ -2213,3 +2175,194 @@ async def get_sas_uri( return cls(pipeline_response, deserialized, {}) # type: ignore return deserialized # type: ignore + + +class ServicesSuiteOffersOperations: # pylint: disable=docstring-missing-param + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~azure.quantum.aio.WorkspaceClient`'s + :attr:`suite_offers` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client: AsyncPipelineClient = input_args.pop(0) if input_args else kwargs.pop("client") + self._config: WorkspaceClientConfiguration = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize: Serializer = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize: Deserializer = input_args.pop(0) if input_args else kwargs.pop("deserializer") + + @distributed_trace + @api_version_validation( + method_added_on="2026-01-15-preview", + params_added_on={"2026-01-15-preview": ["api_version", "subscription_id", "provider_id", "accept"]}, + api_versions_list=["2026-01-15-preview"], + ) + def list_quota_usages( + self, subscription_id: str, provider_id: str, **kwargs: Any + ) -> AsyncItemPaged["_models.QuotaUsageData"]: + """List quota usages for the given suite offer provider in the subscription. + + :param subscription_id: The Azure subscription ID. Required. + :type subscription_id: str + :param provider_id: The unique identifier for the provider. Required. + :type provider_id: str + :return: An iterator like instance of QuotaUsageData + :rtype: ~azure.core.async_paging.AsyncItemPaged[~azure.quantum.models.QuotaUsageData] + :raises ~azure.core.exceptions.HttpResponseError: + """ + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[list[_models.QuotaUsageData]] = kwargs.pop("cls", None) + + error_map: MutableMapping = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + def prepare_request(next_link=None): + if not next_link: + + _request = build_services_suite_offers_list_quota_usages_request( + subscription_id=subscription_id, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + else: + # make call to next link with the client's api-version + _parsed_next_link = urllib.parse.urlparse(next_link) + _next_request_params = case_insensitive_dict( + { + key: [urllib.parse.quote(v) for v in value] + for key, value in urllib.parse.parse_qs(_parsed_next_link.query).items() + } + ) + _next_request_params["api-version"] = self._config.api_version + _request = HttpRequest( + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + return _request + + async def extract_data(pipeline_response): + deserialized = pipeline_response.http_response.json() + list_of_elem = _deserialize( + list[_models.QuotaUsageData], + deserialized.get("value", []), + ) + if cls: + list_of_elem = cls(list_of_elem) # type: ignore + return deserialized.get("nextLink") or None, AsyncList(list_of_elem) + + async def get_next(next_link=None): + _request = prepare_request(next_link) + + _stream = False + pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + response = pipeline_response.http_response + + if response.status_code not in [200]: + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) + + return pipeline_response + + return AsyncItemPaged(get_next, extract_data) + + @distributed_trace_async + @api_version_validation( + method_added_on="2026-01-15-preview", + params_added_on={"2026-01-15-preview": ["api_version", "subscription_id", "provider_id", "accept"]}, + api_versions_list=["2026-01-15-preview"], + ) + async def get_provider_status( + self, subscription_id: str, provider_id: str, **kwargs: Any + ) -> _models.ProviderStatus: + """Get the provider status, including target statuses, for the given suite offer provider in the + subscription. + + :param subscription_id: The Azure subscription ID. Required. + :type subscription_id: str + :param provider_id: The unique identifier for the provider. Required. + :type provider_id: str + :return: ProviderStatus. The ProviderStatus is compatible with MutableMapping + :rtype: ~azure.quantum.models.ProviderStatus + :raises ~azure.core.exceptions.HttpResponseError: + """ + error_map: MutableMapping = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[_models.ProviderStatus] = kwargs.pop("cls", None) + + _request = build_services_suite_offers_get_provider_status_request( + subscription_id=subscription_id, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url("self._config.endpoint", self._config.endpoint, "str", skip_quote=True), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + _decompress = kwargs.pop("decompress", True) + _stream = kwargs.pop("stream", False) + pipeline_response: PipelineResponse = await self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + try: + await response.read() # Load the body in memory and close the socket + except (StreamConsumedError, StreamClosedError): + pass + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) + + if _stream: + deserialized = response.iter_bytes() if _decompress else response.iter_raw() + else: + deserialized = _deserialize(_models.ProviderStatus, response.json()) + + if cls: + return cls(pipeline_response, deserialized, {}) # type: ignore + + return deserialized # type: ignore diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_patch.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_patch.py index 87676c65a8f..ea765788358 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_patch.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_patch.py @@ -8,7 +8,6 @@ Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize """ - __all__: list[str] = [] # Add all objects you want publicly available to users at this package level diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py index 6a0e8d274c4..b4828b895f0 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py @@ -20,11 +20,11 @@ ItemDetails, JobDetails, JobUpdateOptions, + JobUpdateResponse, ProviderStatus, QuantumComputingData, Quota, - QuotaUsage, - QuotaUsageValues, + QuotaUsageData, SasUriResponse, SessionDetails, TargetStatus, @@ -44,6 +44,7 @@ ProviderAvailability, SessionJobFailurePolicy, SessionStatus, + SuiteOfferScope, TargetAvailability, ) from ._patch import __all__ as _patch_all @@ -57,11 +58,11 @@ "ItemDetails", "JobDetails", "JobUpdateOptions", + "JobUpdateResponse", "ProviderStatus", "QuantumComputingData", "Quota", - "QuotaUsage", - "QuotaUsageValues", + "QuotaUsageData", "SasUriResponse", "SessionDetails", "TargetStatus", @@ -78,6 +79,7 @@ "ProviderAvailability", "SessionJobFailurePolicy", "SessionStatus", + "SuiteOfferScope", "TargetAvailability", ] __all__.extend([p for p in _patch_all if p not in __all__]) # pyright: ignore diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_enums.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_enums.py index 7cd9eff3413..55c079af67b 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_enums.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_enums.py @@ -134,6 +134,17 @@ class SessionStatus(str, Enum, metaclass=CaseInsensitiveEnumMeta): """The session timed out.""" +class SuiteOfferScope(str, Enum, metaclass=CaseInsensitiveEnumMeta): + """The scope at which the suite offer quota usage is applied.""" + + TARGET = "Target" + """The usage is applied at the target level.""" + SUBSCRIPTION_TARGET = "SubscriptionTarget" + """The usage is applied at the subscription target level.""" + WORKSPACE_TARGET = "WorkspaceTarget" + """The usage is applied at the workspace target level.""" + + class TargetAvailability(str, Enum, metaclass=CaseInsensitiveEnumMeta): """Target availability.""" diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py index de5caeff09d..4d31168a3ca 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py @@ -20,7 +20,7 @@ from .. import models as _models -class BlobDetails(_Model): +class BlobDetails(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only """The details (name and container) of the blob to store or download data. :ivar container_name: The container name. Required. @@ -53,7 +53,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) -class CostEstimate(_Model): +class CostEstimate(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only """The job cost billed by the provider. The final cost on your bill might be slightly different due to added taxes and currency conversion rates. @@ -96,7 +96,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) -class InnerError(_Model): +class InnerError(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only """An object containing more specific information about the error. As per Azure REST API guidelines - `https://aka.ms/AzureRestApiGuidelines#handling-errors `_. @@ -131,7 +131,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) -class ItemDetails(_Model): +class ItemDetails(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only """A workspace item. You probably want to use the sub-classes and not this class directly. Known sub-classes are: @@ -262,7 +262,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) -class JobDetails(ItemDetails, discriminator="Job"): +class JobDetails(ItemDetails, discriminator="Job"): # pylint: disable=docstring-keyword-should-match-keyword-only """A job to be run in the workspace. :ivar name: The name of the item. It is not required for the name to be unique and it's only @@ -412,7 +412,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.item_type = ItemType.JOB # type: ignore -class JobUpdateOptions(_Model): +class JobUpdateOptions(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only """Options for updating a job. :ivar id: Id of the job. Required. @@ -454,6 +454,46 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) +class JobUpdateResponse(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only + """Response returned when a job update succeeds. + + :ivar name: The name of the job. Required. + :vartype name: str + :ivar priority: Priority of job. Known values are: "Standard" and "High". + :vartype priority: str or ~azure.quantum.models.Priority + :ivar tags: List of user-supplied tags associated with the job. Required. + :vartype tags: list[str] + """ + + name: str = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """The name of the job. Required.""" + priority: Optional[Union[str, "_models.Priority"]] = rest_field( + visibility=["read", "create", "update", "delete", "query"] + ) + """Priority of job. Known values are: \"Standard\" and \"High\".""" + tags: list[str] = rest_field(visibility=["read", "create", "update", "delete", "query"]) + """List of user-supplied tags associated with the job. Required.""" + + @overload + def __init__( + self, + *, + name: str, + tags: list[str], + priority: Optional[Union[str, "_models.Priority"]] = None, + ) -> None: ... + + @overload + def __init__(self, mapping: Mapping[str, Any]) -> None: + """ + :param mapping: raw JSON to initialize the model. + :type mapping: Mapping[str, Any] + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + class ProviderStatus(_Model): """Provider status. @@ -531,60 +571,37 @@ class Quota(_Model): 'None' is used for concurrent quotas. Required. Known values are: \"None\" and \"Monthly\".""" -class QuotaUsageValues(_Model): - """The consumed quota usage values, measured in minutes over the lifetime of the provider - account. - - :ivar standard_minutes_lifetime: The amount of standard priority minutes consumed over the - lifetime of the provider account. - :vartype standard_minutes_lifetime: float - :ivar high_minutes_lifetime: The amount of high priority minutes consumed over the lifetime of - the provider account. - :vartype high_minutes_lifetime: float - """ - - standard_minutes_lifetime: Optional[float] = rest_field(name="standardMinutesLifetime", visibility=["read"]) - """The amount of standard priority minutes consumed over the lifetime of the provider account.""" - high_minutes_lifetime: Optional[float] = rest_field(name="highMinutesLifetime", visibility=["read"]) - """The amount of high priority minutes consumed over the lifetime of the provider account.""" - +class QuotaUsageData(_Model): + """Quota usage data for a suite offer provider. -class QuotaUsage(_Model): - """Quota usage information for a suite offer provider account. - - :ivar id: The unique identifier of the quota usage record. Required. - :vartype id: str - :ivar provider_id: The unique identifier for the provider account. Required. + :ivar provider_id: The unique identifier for the provider. Required. :vartype provider_id: str - :ivar scope: The scope at which the quota usage is measured. Required. - :vartype scope: str - :ivar target_id: The identifier of the target the usage applies to, when the scope is - target-specific. + :ivar scope: The scope at which the quota usage is applied. Required. Known values are: + "Target", "SubscriptionTarget", and "WorkspaceTarget". + :vartype scope: str or ~azure.quantum.models.SuiteOfferScope + :ivar target_id: The unique identifier for the target, when the usage is scoped to a target. :vartype target_id: str - :ivar usage: The consumed quota usage values. Required. - :vartype usage: ~azure.quantum.models.QuotaUsageValues - :ivar last_modified_time: The timestamp of the last modification of the quota usage record. + :ivar usage: The accumulated quota usage values. Required. + :vartype usage: ~azure.quantum.models.Usage + :ivar last_modified_time: The time when the quota usage was last modified. Required. :vartype last_modified_time: ~datetime.datetime - :ivar metadata: Additional metadata associated with the quota usage record. + :ivar metadata: Additional metadata associated with the quota usage. :vartype metadata: dict[str, str] """ - id: str = rest_field(visibility=["read"]) - """The unique identifier of the quota usage record. Required.""" provider_id: str = rest_field(name="providerId", visibility=["read"]) - """The unique identifier for the provider account. Required.""" - scope: str = rest_field(visibility=["read"]) - """The scope at which the quota usage is measured. Required.""" + """The unique identifier for the provider. Required.""" + scope: Union[str, "_models.SuiteOfferScope"] = rest_field(visibility=["read"]) + """The scope at which the quota usage is applied. Required. Known values are: \"Target\", + \"SubscriptionTarget\", and \"WorkspaceTarget\".""" target_id: Optional[str] = rest_field(name="targetId", visibility=["read"]) - """The identifier of the target the usage applies to, when the scope is target-specific.""" - usage: "_models.QuotaUsageValues" = rest_field(visibility=["read"]) - """The consumed quota usage values. Required.""" - last_modified_time: Optional[datetime.datetime] = rest_field( - name="lastModifiedTime", visibility=["read"], format="rfc3339" - ) - """The timestamp of the last modification of the quota usage record.""" + """The unique identifier for the target, when the usage is scoped to a target.""" + usage: "_models.Usage" = rest_field(visibility=["read"]) + """The accumulated quota usage values. Required.""" + last_modified_time: datetime.datetime = rest_field(name="lastModifiedTime", visibility=["read"], format="rfc3339") + """The time when the quota usage was last modified. Required.""" metadata: Optional[dict[str, str]] = rest_field(visibility=["read"]) - """Additional metadata associated with the quota usage record.""" + """Additional metadata associated with the quota usage.""" class SasUriResponse(_Model): @@ -599,7 +616,9 @@ class SasUriResponse(_Model): """A URL with a SAS token to upload a blob for execution in the given workspace. Required.""" -class SessionDetails(ItemDetails, discriminator="Session"): +class SessionDetails( + ItemDetails, discriminator="Session" +): # pylint: disable=docstring-keyword-should-match-keyword-only """Session, a logical grouping of jobs. :ivar name: The name of the item. It is not required for the name to be unique and it's only @@ -699,6 +718,10 @@ class TargetStatus(_Model): :vartype current_availability: str or ~azure.quantum.models.TargetAvailability :ivar average_queue_time: Average queue time in seconds. Required. :vartype average_queue_time: int + :ivar average_queue_time_high_priority: Average high-priority queue time in seconds. + :vartype average_queue_time_high_priority: int + :ivar average_queue_time_standard_priority: Average standard-priority queue time in seconds. + :vartype average_queue_time_standard_priority: int :ivar status_page: A page with detailed status of the provider. :vartype status_page: str :ivar num_qubits: The qubit number. @@ -718,6 +741,14 @@ class TargetStatus(_Model): \"Unavailable\".""" average_queue_time: int = rest_field(name="averageQueueTime", visibility=["read"]) """Average queue time in seconds. Required.""" + average_queue_time_high_priority: Optional[int] = rest_field( + name="averageQueueTimeHighPriority", visibility=["read"] + ) + """Average high-priority queue time in seconds.""" + average_queue_time_standard_priority: Optional[int] = rest_field( + name="averageQueueTimeStandardPriority", visibility=["read"] + ) + """Average standard-priority queue time in seconds.""" status_page: Optional[str] = rest_field(name="statusPage", visibility=["read"]) """A page with detailed status of the provider.""" num_qubits: Optional[int] = rest_field(name="numQubits", visibility=["read"]) @@ -736,7 +767,7 @@ class Usage(_Model): """ -class UsageEvent(_Model): +class UsageEvent(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only """Usage event details. :ivar dimension_id: The dimension id. Required. @@ -791,7 +822,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) -class WorkspaceItemError(_Model): +class WorkspaceItemError(_Model): # pylint: disable=docstring-keyword-should-match-keyword-only """The error object. :ivar code: One of a server-defined set of error codes. Required. diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_patch.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_patch.py index 87676c65a8f..ea765788358 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_patch.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_patch.py @@ -8,7 +8,6 @@ Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize """ - __all__: list[str] = [] # Add all objects you want publicly available to users at this package level diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py index 1acb4856e88..1aa81764d80 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py @@ -29,7 +29,7 @@ from azure.core.tracing.decorator import distributed_trace from azure.core.utils import case_insensitive_dict -from .. import models as _models +from .. import models as _models, types as _types from .._configuration import WorkspaceClientConfiguration from .._utils.model_base import SdkJSONEncoder, _deserialize from .._utils.serialization import Deserializer, Serializer @@ -37,7 +37,6 @@ T = TypeVar("T") ClsType = Optional[Callable[[PipelineResponse[HttpRequest, HttpResponse], T, dict[str, Any]], Any]] -JSON = MutableMapping[str, Any] _SERIALIZER = Serializer() _SERIALIZER.client_side_validation = False @@ -130,7 +129,7 @@ def build_services_jobs_update_request( accept = _headers.pop("Accept", "application/json") # Construct URL - _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/jobUpdateOptions/{jobId}" + _url = "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Quantum/workspaces/{workspaceName}/jobs/{jobId}" path_format_arguments = { "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), "resourceGroupName": _SERIALIZER.url("resource_group_name", resource_group_name, "str"), @@ -332,8 +331,8 @@ def build_services_quotas_list_request( return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) -def build_services_quotas_list_quota_usages_request( # pylint: disable=name-too-long - subscription_id: str, resource_group_name: str, workspace_name: str, provider_id: str, **kwargs: Any +def build_services_quotas_list_workspace_usages_request( # pylint: disable=name-too-long + subscription_id: str, resource_group_name: str, workspace_name: str, *, provider_id: str, **kwargs: Any ) -> HttpRequest: _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) @@ -351,63 +350,9 @@ def build_services_quotas_list_quota_usages_request( # pylint: disable=name-too _url: str = _url.format(**path_format_arguments) # type: ignore - # Construct parameters - _params["providerId"] = _SERIALIZER.query("provider_id", provider_id, "str") - _params["api-version"] = _SERIALIZER.query("api_version", api_version, "str") - - # Construct headers - _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") - - return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) - - -def build_services_suite_offers_list_quota_usages_request( # pylint: disable=name-too-long - subscription_id: str, provider_id: str, **kwargs: Any -) -> HttpRequest: - _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) - _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) - - api_version: str = kwargs.pop("api_version", _params.pop("api-version", "2026-01-15-preview")) - accept = _headers.pop("Accept", "application/json") - - # Construct URL - _url = "/subscriptions/{subscriptionId}/providers/Microsoft.Quantum/suiteOffers/{providerId}/quotaUsages" - path_format_arguments = { - "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), - "providerId": _SERIALIZER.url("provider_id", provider_id, "str"), - } - - _url: str = _url.format(**path_format_arguments) # type: ignore - - # Construct parameters - _params["api-version"] = _SERIALIZER.query("api_version", api_version, "str") - - # Construct headers - _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") - - return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) - - -def build_services_suite_offers_get_provider_status_request( # pylint: disable=name-too-long - subscription_id: str, provider_id: str, **kwargs: Any -) -> HttpRequest: - _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) - _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) - - api_version: str = kwargs.pop("api_version", _params.pop("api-version", "2026-01-15-preview")) - accept = _headers.pop("Accept", "application/json") - - # Construct URL - _url = "/subscriptions/{subscriptionId}/providers/Microsoft.Quantum/suiteOffers/{providerId}/providerStatus" - path_format_arguments = { - "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), - "providerId": _SERIALIZER.url("provider_id", provider_id, "str"), - } - - _url: str = _url.format(**path_format_arguments) # type: ignore - # Construct parameters _params["api-version"] = _SERIALIZER.query("api_version", api_version, "str") + _params["providerId"] = _SERIALIZER.query("provider_id", provider_id, "str") # Construct headers _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") @@ -626,7 +571,61 @@ def build_services_storage_get_sas_uri_request( # pylint: disable=name-too-long return HttpRequest(method="POST", url=_url, params=_params, headers=_headers, **kwargs) -class ServicesOperations: +def build_services_suite_offers_list_quota_usages_request( # pylint: disable=name-too-long + subscription_id: str, provider_id: str, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + api_version: str = kwargs.pop("api_version", _params.pop("api-version", "2026-01-15-preview")) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/subscriptions/{subscriptionId}/providers/Microsoft.Quantum/suiteOffers/{providerId}/quotaUsages" + path_format_arguments = { + "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), + "providerId": _SERIALIZER.url("provider_id", provider_id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct parameters + _params["api-version"] = _SERIALIZER.query("api_version", api_version, "str") + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) + + +def build_services_suite_offers_get_provider_status_request( # pylint: disable=name-too-long + subscription_id: str, provider_id: str, **kwargs: Any +) -> HttpRequest: + _headers = case_insensitive_dict(kwargs.pop("headers", {}) or {}) + _params = case_insensitive_dict(kwargs.pop("params", {}) or {}) + + api_version: str = kwargs.pop("api_version", _params.pop("api-version", "2026-01-15-preview")) + accept = _headers.pop("Accept", "application/json") + + # Construct URL + _url = "/subscriptions/{subscriptionId}/providers/Microsoft.Quantum/suiteOffers/{providerId}/providerStatus" + path_format_arguments = { + "subscriptionId": _SERIALIZER.url("subscription_id", subscription_id, "str"), + "providerId": _SERIALIZER.url("provider_id", provider_id, "str"), + } + + _url: str = _url.format(**path_format_arguments) # type: ignore + + # Construct parameters + _params["api-version"] = _SERIALIZER.query("api_version", api_version, "str") + + # Construct headers + _headers["Accept"] = _SERIALIZER.header("accept", accept, "str") + + return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) + + +class ServicesOperations: # pylint: disable=docstring-missing-param,too-many-instance-attributes """ .. warning:: **DO NOT** instantiate this class directly. @@ -649,14 +648,14 @@ def __init__(self, *args, **kwargs) -> None: self.jobs = ServicesJobsOperations(self._client, self._config, self._serialize, self._deserialize) self.providers = ServicesProvidersOperations(self._client, self._config, self._serialize, self._deserialize) self.quotas = ServicesQuotasOperations(self._client, self._config, self._serialize, self._deserialize) + self.sessions = ServicesSessionsOperations(self._client, self._config, self._serialize, self._deserialize) + self.storage = ServicesStorageOperations(self._client, self._config, self._serialize, self._deserialize) self.suite_offers = ServicesSuiteOffersOperations( self._client, self._config, self._serialize, self._deserialize ) - self.sessions = ServicesSessionsOperations(self._client, self._config, self._serialize, self._deserialize) - self.storage = ServicesStorageOperations(self._client, self._config, self._serialize, self._deserialize) -class ServicesTopLevelItemsOperations: +class ServicesTopLevelItemsOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -769,7 +768,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -782,7 +784,10 @@ def prepare_request(next_link=None): def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.ItemDetails], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.ItemDetails], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, iter(list_of_elem) @@ -805,7 +810,7 @@ def get_next(next_link=None): return ItemPaged(get_next, extract_data) -class ServicesJobsOperations: +class ServicesJobsOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -861,7 +866,7 @@ def create( resource_group_name: str, workspace_name: str, job_id: str, - resource: JSON, + resource: _types.JobDetails, *, content_type: str = "application/json", **kwargs: Any @@ -877,7 +882,7 @@ def create( :param job_id: Id of the job. Required. :type job_id: str :param resource: The resource instance. Required. - :type resource: JSON + :type resource: ~azure.quantum.types.JobDetails :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. Default value is "application/json". :paramtype content_type: str @@ -925,7 +930,7 @@ def create( resource_group_name: str, workspace_name: str, job_id: str, - resource: Union[_models.JobDetails, JSON, IO[bytes]], + resource: Union[_models.JobDetails, _types.JobDetails, IO[bytes]], **kwargs: Any ) -> _models.JobDetails: """Create a new job. @@ -938,9 +943,10 @@ def create( :type workspace_name: str :param job_id: Id of the job. Required. :type job_id: str - :param resource: The resource instance. Is one of the following types: JobDetails, JSON, - IO[bytes] Required. - :type resource: ~azure.quantum.models.JobDetails or JSON or IO[bytes] + :param resource: The resource instance. Is either a JobDetails type or a IO[bytes] type. + Required. + :type resource: ~azure.quantum.models.JobDetails or ~azure.quantum.types.JobDetails or + IO[bytes] :return: JobDetails. The JobDetails is compatible with MutableMapping :rtype: ~azure.quantum.models.JobDetails :raises ~azure.core.exceptions.HttpResponseError: @@ -982,6 +988,7 @@ def create( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -999,7 +1006,7 @@ def create( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.JobDetails, response.json()) @@ -1019,7 +1026,7 @@ def update( *, content_type: str = "application/merge-patch+json", **kwargs: Any - ) -> _models.JobUpdateOptions: + ) -> _models.JobUpdateResponse: """Update job properties. :param subscription_id: The Azure subscription ID. Required. @@ -1035,8 +1042,8 @@ def update( :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. Default value is "application/merge-patch+json". :paramtype content_type: str - :return: JobUpdateOptions. The JobUpdateOptions is compatible with MutableMapping - :rtype: ~azure.quantum.models.JobUpdateOptions + :return: JobUpdateResponse. The JobUpdateResponse is compatible with MutableMapping + :rtype: ~azure.quantum.models.JobUpdateResponse :raises ~azure.core.exceptions.HttpResponseError: """ @@ -1047,11 +1054,11 @@ def update( resource_group_name: str, workspace_name: str, job_id: str, - resource: JSON, + resource: _types.JobUpdateOptions, *, content_type: str = "application/merge-patch+json", **kwargs: Any - ) -> _models.JobUpdateOptions: + ) -> _models.JobUpdateResponse: """Update job properties. :param subscription_id: The Azure subscription ID. Required. @@ -1063,12 +1070,12 @@ def update( :param job_id: Id of the job. Required. :type job_id: str :param resource: The resource instance. Required. - :type resource: JSON + :type resource: ~azure.quantum.types.JobUpdateOptions :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. Default value is "application/merge-patch+json". :paramtype content_type: str - :return: JobUpdateOptions. The JobUpdateOptions is compatible with MutableMapping - :rtype: ~azure.quantum.models.JobUpdateOptions + :return: JobUpdateResponse. The JobUpdateResponse is compatible with MutableMapping + :rtype: ~azure.quantum.models.JobUpdateResponse :raises ~azure.core.exceptions.HttpResponseError: """ @@ -1083,7 +1090,7 @@ def update( *, content_type: str = "application/merge-patch+json", **kwargs: Any - ) -> _models.JobUpdateOptions: + ) -> _models.JobUpdateResponse: """Update job properties. :param subscription_id: The Azure subscription ID. Required. @@ -1099,8 +1106,8 @@ def update( :keyword content_type: Body Parameter content-type. Content type parameter for binary body. Default value is "application/merge-patch+json". :paramtype content_type: str - :return: JobUpdateOptions. The JobUpdateOptions is compatible with MutableMapping - :rtype: ~azure.quantum.models.JobUpdateOptions + :return: JobUpdateResponse. The JobUpdateResponse is compatible with MutableMapping + :rtype: ~azure.quantum.models.JobUpdateResponse :raises ~azure.core.exceptions.HttpResponseError: """ @@ -1126,9 +1133,9 @@ def update( resource_group_name: str, workspace_name: str, job_id: str, - resource: Union[_models.JobUpdateOptions, JSON, IO[bytes]], + resource: Union[_models.JobUpdateOptions, _types.JobUpdateOptions, IO[bytes]], **kwargs: Any - ) -> _models.JobUpdateOptions: + ) -> _models.JobUpdateResponse: """Update job properties. :param subscription_id: The Azure subscription ID. Required. @@ -1139,11 +1146,12 @@ def update( :type workspace_name: str :param job_id: Id of the job. Required. :type job_id: str - :param resource: The resource instance. Is one of the following types: JobUpdateOptions, JSON, - IO[bytes] Required. - :type resource: ~azure.quantum.models.JobUpdateOptions or JSON or IO[bytes] - :return: JobUpdateOptions. The JobUpdateOptions is compatible with MutableMapping - :rtype: ~azure.quantum.models.JobUpdateOptions + :param resource: The resource instance. Is either a JobUpdateOptions type or a IO[bytes] type. + Required. + :type resource: ~azure.quantum.models.JobUpdateOptions or ~azure.quantum.types.JobUpdateOptions + or IO[bytes] + :return: JobUpdateResponse. The JobUpdateResponse is compatible with MutableMapping + :rtype: ~azure.quantum.models.JobUpdateResponse :raises ~azure.core.exceptions.HttpResponseError: """ error_map: MutableMapping = { @@ -1158,7 +1166,7 @@ def update( _params = kwargs.pop("params", {}) or {} content_type: Optional[str] = kwargs.pop("content_type", _headers.pop("Content-Type", None)) - cls: ClsType[_models.JobUpdateOptions] = kwargs.pop("cls", None) + cls: ClsType[_models.JobUpdateResponse] = kwargs.pop("cls", None) content_type = content_type or "application/merge-patch+json" _content = None @@ -1183,6 +1191,7 @@ def update( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -1200,9 +1209,9 @@ def update( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: - deserialized = _deserialize(_models.JobUpdateOptions, response.json()) + deserialized = _deserialize(_models.JobUpdateResponse, response.json()) if cls: return cls(pipeline_response, deserialized, {}) # type: ignore @@ -1327,6 +1336,7 @@ def cancel( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -1344,7 +1354,7 @@ def cancel( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.JobDetails, response.json()) @@ -1398,6 +1408,7 @@ def get( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -1415,7 +1426,7 @@ def get( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.JobDetails, response.json()) @@ -1513,7 +1524,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -1526,7 +1540,10 @@ def prepare_request(next_link=None): def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.JobDetails], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.JobDetails], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, iter(list_of_elem) @@ -1549,7 +1566,7 @@ def get_next(next_link=None): return ItemPaged(get_next, extract_data) -class ServicesProvidersOperations: +class ServicesProvidersOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -1624,7 +1641,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -1637,7 +1657,10 @@ def prepare_request(next_link=None): def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.ProviderStatus], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.ProviderStatus], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, iter(list_of_elem) @@ -1660,7 +1683,7 @@ def get_next(next_link=None): return ItemPaged(get_next, extract_data) -class ServicesQuotasOperations: +class ServicesQuotasOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -1735,7 +1758,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -1748,7 +1774,10 @@ def prepare_request(next_link=None): def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.Quota], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.Quota], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, iter(list_of_elem) @@ -1771,10 +1800,24 @@ def get_next(next_link=None): return ItemPaged(get_next, extract_data) @distributed_trace - def list_quota_usages( - self, subscription_id: str, resource_group_name: str, workspace_name: str, provider_id: str, **kwargs: Any - ) -> "list[_models.QuotaUsage]": - """List quota usages for a provider in the given workspace. + @api_version_validation( + method_added_on="2026-01-15-preview", + params_added_on={ + "2026-01-15-preview": [ + "api_version", + "subscription_id", + "resource_group_name", + "workspace_name", + "provider_id", + "accept", + ] + }, + api_versions_list=["2026-01-15-preview"], + ) + def list_workspace_usages( + self, subscription_id: str, resource_group_name: str, workspace_name: str, *, provider_id: str, **kwargs: Any + ) -> ItemPaged["_models.QuotaUsageData"]: + """List quota usages for the given workspace. This operation is only available for v2 workspaces. :param subscription_id: The Azure subscription ID. Required. :type subscription_id: str @@ -1782,96 +1825,16 @@ def list_quota_usages( :type resource_group_name: str :param workspace_name: Name of the Azure Quantum workspace. Required. :type workspace_name: str - :param provider_id: The provider whose quota usages are requested. Required. - :type provider_id: str - :return: list of QuotaUsage - :rtype: list[~azure.quantum.models.QuotaUsage] - :raises ~azure.core.exceptions.HttpResponseError: - """ - _headers = kwargs.pop("headers", {}) or {} - _params = kwargs.pop("params", {}) or {} - - cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) - - error_map: MutableMapping = { - 401: ClientAuthenticationError, - 404: ResourceNotFoundError, - 409: ResourceExistsError, - 304: ResourceNotModifiedError, - } - error_map.update(kwargs.pop("error_map", {}) or {}) - - _request = build_services_quotas_list_quota_usages_request( - subscription_id=subscription_id, - resource_group_name=resource_group_name, - workspace_name=workspace_name, - provider_id=provider_id, - api_version=self._config.api_version, - headers=_headers, - params=_params, - ) - path_format_arguments = { - "endpoint": self._serialize.url( - "self._config.endpoint", self._config.endpoint, "str", skip_quote=True - ), - } - _request.url = self._client.format_url(_request.url, **path_format_arguments) - - _stream = False - pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access - _request, stream=_stream, **kwargs - ) - response = pipeline_response.http_response - - if response.status_code not in [200]: - map_error(status_code=response.status_code, response=response, error_map=error_map) - raise HttpResponseError(response=response) - - deserialized = response.json() - # The service may return either a bare JSON array or a paged envelope ({"value": [...]}). - if isinstance(deserialized, dict): - deserialized = deserialized.get("value", []) - list_of_elem = _deserialize(list[_models.QuotaUsage], deserialized) - if cls: - return cls(list_of_elem) # type: ignore - return list_of_elem - - -class ServicesSuiteOffersOperations: - """ - .. warning:: - **DO NOT** instantiate this class directly. - - Instead, you should access the following operations through - :class:`~azure.quantum.WorkspaceClient`'s - :attr:`suite_offers` attribute. - """ - - def __init__(self, *args, **kwargs) -> None: - input_args = list(args) - self._client: PipelineClient = input_args.pop(0) if input_args else kwargs.pop("client") - self._config: WorkspaceClientConfiguration = input_args.pop(0) if input_args else kwargs.pop("config") - self._serialize: Serializer = input_args.pop(0) if input_args else kwargs.pop("serializer") - self._deserialize: Deserializer = input_args.pop(0) if input_args else kwargs.pop("deserializer") - - @distributed_trace - def list_quota_usages( - self, subscription_id: str, provider_id: str, **kwargs: Any - ) -> list["_models.QuotaUsage"]: - """List quota usages for the given suite offer provider account. - - :param subscription_id: The Azure subscription ID. Required. - :type subscription_id: str - :param provider_id: The unique identifier of the suite offer provider account. Required. - :type provider_id: str - :return: list of QuotaUsage - :rtype: list[~azure.quantum.models.QuotaUsage] + :keyword provider_id: The unique identifier for the provider to get quota usages for. Required. + :paramtype provider_id: str + :return: An iterator like instance of QuotaUsageData + :rtype: ~azure.core.paging.ItemPaged[~azure.quantum.models.QuotaUsageData] :raises ~azure.core.exceptions.HttpResponseError: """ _headers = kwargs.pop("headers", {}) or {} _params = kwargs.pop("params", {}) or {} - cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) + cls: ClsType[list[_models.QuotaUsageData]] = kwargs.pop("cls", None) error_map: MutableMapping = { 401: ClientAuthenticationError, @@ -1881,98 +1844,79 @@ def list_quota_usages( } error_map.update(kwargs.pop("error_map", {}) or {}) - _request = build_services_suite_offers_list_quota_usages_request( - subscription_id=subscription_id, - provider_id=provider_id, - api_version=self._config.api_version, - headers=_headers, - params=_params, - ) - path_format_arguments = { - "endpoint": self._serialize.url( - "self._config.endpoint", self._config.endpoint, "str", skip_quote=True - ), - } - _request.url = self._client.format_url(_request.url, **path_format_arguments) - - _stream = False - pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access - _request, stream=_stream, **kwargs - ) - response = pipeline_response.http_response - - if response.status_code not in [200]: - map_error(status_code=response.status_code, response=response, error_map=error_map) - raise HttpResponseError(response=response) + def prepare_request(next_link=None): + if not next_link: - deserialized = response.json() - # The service may return either a bare JSON array or a paged envelope ({"value": [...]}). - if isinstance(deserialized, dict): - deserialized = deserialized.get("value", []) - list_of_elem = _deserialize(list[_models.QuotaUsage], deserialized) - if cls: - return cls(list_of_elem) # type: ignore - return list_of_elem + _request = build_services_quotas_list_workspace_usages_request( + subscription_id=subscription_id, + resource_group_name=resource_group_name, + workspace_name=workspace_name, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) - @distributed_trace - def get_provider_status( - self, subscription_id: str, provider_id: str, **kwargs: Any - ) -> "_models.ProviderStatus": - """Get the target status for the given suite offer provider account. + else: + # make call to next link with the client's api-version + _parsed_next_link = urllib.parse.urlparse(next_link) + _next_request_params = case_insensitive_dict( + { + key: [urllib.parse.quote(v) for v in value] + for key, value in urllib.parse.parse_qs(_parsed_next_link.query).items() + } + ) + _next_request_params["api-version"] = self._config.api_version + _request = HttpRequest( + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) - :param subscription_id: The Azure subscription ID. Required. - :type subscription_id: str - :param provider_id: The unique identifier of the suite offer provider account. Required. - :type provider_id: str - :return: ProviderStatus - :rtype: ~azure.quantum.models.ProviderStatus - :raises ~azure.core.exceptions.HttpResponseError: - """ - _headers = kwargs.pop("headers", {}) or {} - _params = kwargs.pop("params", {}) or {} + return _request - cls: ClsType[_models.ProviderStatus] = kwargs.pop("cls", None) + def extract_data(pipeline_response): + deserialized = pipeline_response.http_response.json() + list_of_elem = _deserialize( + list[_models.QuotaUsageData], + deserialized.get("value", []), + ) + if cls: + list_of_elem = cls(list_of_elem) # type: ignore + return deserialized.get("nextLink") or None, iter(list_of_elem) - error_map: MutableMapping = { - 401: ClientAuthenticationError, - 404: ResourceNotFoundError, - 409: ResourceExistsError, - 304: ResourceNotModifiedError, - } - error_map.update(kwargs.pop("error_map", {}) or {}) + def get_next(next_link=None): + _request = prepare_request(next_link) - _request = build_services_suite_offers_get_provider_status_request( - subscription_id=subscription_id, - provider_id=provider_id, - api_version=self._config.api_version, - headers=_headers, - params=_params, - ) - path_format_arguments = { - "endpoint": self._serialize.url( - "self._config.endpoint", self._config.endpoint, "str", skip_quote=True - ), - } - _request.url = self._client.format_url(_request.url, **path_format_arguments) + _stream = False + pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + response = pipeline_response.http_response - _stream = False - pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access - _request, stream=_stream, **kwargs - ) - response = pipeline_response.http_response + if response.status_code not in [200]: + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) - if response.status_code not in [200]: - map_error(status_code=response.status_code, response=response, error_map=error_map) - raise HttpResponseError(response=response) + return pipeline_response - # The provider status endpoint returns a single ProviderStatus object. - deserialized = _deserialize(_models.ProviderStatus, response.json()) - if cls: - return cls(deserialized) # type: ignore - return deserialized + return ItemPaged(get_next, extract_data) -class ServicesSessionsOperations: +class ServicesSessionsOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -2028,7 +1972,7 @@ def open( resource_group_name: str, workspace_name: str, session_id: str, - resource: JSON, + resource: _types.SessionDetails, *, content_type: str = "application/json", **kwargs: Any @@ -2044,7 +1988,7 @@ def open( :param session_id: Id of the session. Required. :type session_id: str :param resource: The resource instance. Required. - :type resource: JSON + :type resource: ~azure.quantum.types.SessionDetails :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. Default value is "application/json". :paramtype content_type: str @@ -2092,7 +2036,7 @@ def open( resource_group_name: str, workspace_name: str, session_id: str, - resource: Union[_models.SessionDetails, JSON, IO[bytes]], + resource: Union[_models.SessionDetails, _types.SessionDetails, IO[bytes]], **kwargs: Any ) -> _models.SessionDetails: """Open a new session. @@ -2105,9 +2049,10 @@ def open( :type workspace_name: str :param session_id: Id of the session. Required. :type session_id: str - :param resource: The resource instance. Is one of the following types: SessionDetails, JSON, - IO[bytes] Required. - :type resource: ~azure.quantum.models.SessionDetails or JSON or IO[bytes] + :param resource: The resource instance. Is either a SessionDetails type or a IO[bytes] type. + Required. + :type resource: ~azure.quantum.models.SessionDetails or ~azure.quantum.types.SessionDetails or + IO[bytes] :return: SessionDetails. The SessionDetails is compatible with MutableMapping :rtype: ~azure.quantum.models.SessionDetails :raises ~azure.core.exceptions.HttpResponseError: @@ -2149,6 +2094,7 @@ def open( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -2166,7 +2112,7 @@ def open( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.SessionDetails, response.json()) @@ -2220,6 +2166,7 @@ def close( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -2237,7 +2184,7 @@ def close( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.SessionDetails, response.json()) @@ -2291,6 +2238,7 @@ def get( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -2308,7 +2256,7 @@ def get( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.SessionDetails, response.json()) @@ -2413,7 +2361,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -2426,7 +2377,10 @@ def prepare_request(next_link=None): def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.SessionDetails], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.SessionDetails], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, iter(list_of_elem) @@ -2541,7 +2495,10 @@ def prepare_request(next_link=None): ) _next_request_params["api-version"] = self._config.api_version _request = HttpRequest( - "GET", urllib.parse.urljoin(next_link, _parsed_next_link.path), params=_next_request_params + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, ) path_format_arguments = { "endpoint": self._serialize.url( @@ -2554,7 +2511,10 @@ def prepare_request(next_link=None): def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() - list_of_elem = _deserialize(list[_models.JobDetails], deserialized.get("value", [])) + list_of_elem = _deserialize( + list[_models.JobDetails], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, iter(list_of_elem) @@ -2577,7 +2537,7 @@ def get_next(next_link=None): return ItemPaged(get_next, extract_data) -class ServicesStorageOperations: +class ServicesStorageOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -2632,7 +2592,7 @@ def get_sas_uri( subscription_id: str, resource_group_name: str, workspace_name: str, - blob_details: JSON, + blob_details: _types.BlobDetails, *, content_type: str = "application/json", **kwargs: Any @@ -2649,7 +2609,7 @@ def get_sas_uri( :param workspace_name: Name of the Azure Quantum workspace. Required. :type workspace_name: str :param blob_details: The details (name and container) of the blob. Required. - :type blob_details: JSON + :type blob_details: ~azure.quantum.types.BlobDetails :keyword content_type: Body Parameter content-type. Content type parameter for JSON body. Default value is "application/json". :paramtype content_type: str @@ -2696,7 +2656,7 @@ def get_sas_uri( subscription_id: str, resource_group_name: str, workspace_name: str, - blob_details: Union[_models.BlobDetails, JSON, IO[bytes]], + blob_details: Union[_models.BlobDetails, _types.BlobDetails, IO[bytes]], **kwargs: Any ) -> _models.SasUriResponse: """Gets a URL with SAS token for a container/blob in the storage account associated with the @@ -2710,9 +2670,10 @@ def get_sas_uri( :type resource_group_name: str :param workspace_name: Name of the Azure Quantum workspace. Required. :type workspace_name: str - :param blob_details: The details (name and container) of the blob. Is one of the following - types: BlobDetails, JSON, IO[bytes] Required. - :type blob_details: ~azure.quantum.models.BlobDetails or JSON or IO[bytes] + :param blob_details: The details (name and container) of the blob. Is either a BlobDetails type + or a IO[bytes] type. Required. + :type blob_details: ~azure.quantum.models.BlobDetails or ~azure.quantum.types.BlobDetails or + IO[bytes] :return: SasUriResponse. The SasUriResponse is compatible with MutableMapping :rtype: ~azure.quantum.models.SasUriResponse :raises ~azure.core.exceptions.HttpResponseError: @@ -2753,6 +2714,7 @@ def get_sas_uri( } _request.url = self._client.format_url(_request.url, **path_format_arguments) + _decompress = kwargs.pop("decompress", True) _stream = kwargs.pop("stream", False) pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access _request, stream=_stream, **kwargs @@ -2770,7 +2732,7 @@ def get_sas_uri( raise HttpResponseError(response=response) if _stream: - deserialized = response.iter_bytes() + deserialized = response.iter_bytes() if _decompress else response.iter_raw() else: deserialized = _deserialize(_models.SasUriResponse, response.json()) @@ -2778,3 +2740,192 @@ def get_sas_uri( return cls(pipeline_response, deserialized, {}) # type: ignore return deserialized # type: ignore + + +class ServicesSuiteOffersOperations: # pylint: disable=docstring-missing-param + """ + .. warning:: + **DO NOT** instantiate this class directly. + + Instead, you should access the following operations through + :class:`~azure.quantum.WorkspaceClient`'s + :attr:`suite_offers` attribute. + """ + + def __init__(self, *args, **kwargs) -> None: + input_args = list(args) + self._client: PipelineClient = input_args.pop(0) if input_args else kwargs.pop("client") + self._config: WorkspaceClientConfiguration = input_args.pop(0) if input_args else kwargs.pop("config") + self._serialize: Serializer = input_args.pop(0) if input_args else kwargs.pop("serializer") + self._deserialize: Deserializer = input_args.pop(0) if input_args else kwargs.pop("deserializer") + + @distributed_trace + @api_version_validation( + method_added_on="2026-01-15-preview", + params_added_on={"2026-01-15-preview": ["api_version", "subscription_id", "provider_id", "accept"]}, + api_versions_list=["2026-01-15-preview"], + ) + def list_quota_usages( + self, subscription_id: str, provider_id: str, **kwargs: Any + ) -> ItemPaged["_models.QuotaUsageData"]: + """List quota usages for the given suite offer provider in the subscription. + + :param subscription_id: The Azure subscription ID. Required. + :type subscription_id: str + :param provider_id: The unique identifier for the provider. Required. + :type provider_id: str + :return: An iterator like instance of QuotaUsageData + :rtype: ~azure.core.paging.ItemPaged[~azure.quantum.models.QuotaUsageData] + :raises ~azure.core.exceptions.HttpResponseError: + """ + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[list[_models.QuotaUsageData]] = kwargs.pop("cls", None) + + error_map: MutableMapping = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + def prepare_request(next_link=None): + if not next_link: + + _request = build_services_suite_offers_list_quota_usages_request( + subscription_id=subscription_id, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + else: + # make call to next link with the client's api-version + _parsed_next_link = urllib.parse.urlparse(next_link) + _next_request_params = case_insensitive_dict( + { + key: [urllib.parse.quote(v) for v in value] + for key, value in urllib.parse.parse_qs(_parsed_next_link.query).items() + } + ) + _next_request_params["api-version"] = self._config.api_version + _request = HttpRequest( + "GET", + urllib.parse.urljoin(next_link, _parsed_next_link.path), + headers=_headers, + params=_next_request_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url( + "self._config.endpoint", self._config.endpoint, "str", skip_quote=True + ), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + return _request + + def extract_data(pipeline_response): + deserialized = pipeline_response.http_response.json() + list_of_elem = _deserialize( + list[_models.QuotaUsageData], + deserialized.get("value", []), + ) + if cls: + list_of_elem = cls(list_of_elem) # type: ignore + return deserialized.get("nextLink") or None, iter(list_of_elem) + + def get_next(next_link=None): + _request = prepare_request(next_link) + + _stream = False + pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + response = pipeline_response.http_response + + if response.status_code not in [200]: + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) + + return pipeline_response + + return ItemPaged(get_next, extract_data) + + @distributed_trace + @api_version_validation( + method_added_on="2026-01-15-preview", + params_added_on={"2026-01-15-preview": ["api_version", "subscription_id", "provider_id", "accept"]}, + api_versions_list=["2026-01-15-preview"], + ) + def get_provider_status(self, subscription_id: str, provider_id: str, **kwargs: Any) -> _models.ProviderStatus: + """Get the provider status, including target statuses, for the given suite offer provider in the + subscription. + + :param subscription_id: The Azure subscription ID. Required. + :type subscription_id: str + :param provider_id: The unique identifier for the provider. Required. + :type provider_id: str + :return: ProviderStatus. The ProviderStatus is compatible with MutableMapping + :rtype: ~azure.quantum.models.ProviderStatus + :raises ~azure.core.exceptions.HttpResponseError: + """ + error_map: MutableMapping = { + 401: ClientAuthenticationError, + 404: ResourceNotFoundError, + 409: ResourceExistsError, + 304: ResourceNotModifiedError, + } + error_map.update(kwargs.pop("error_map", {}) or {}) + + _headers = kwargs.pop("headers", {}) or {} + _params = kwargs.pop("params", {}) or {} + + cls: ClsType[_models.ProviderStatus] = kwargs.pop("cls", None) + + _request = build_services_suite_offers_get_provider_status_request( + subscription_id=subscription_id, + provider_id=provider_id, + api_version=self._config.api_version, + headers=_headers, + params=_params, + ) + path_format_arguments = { + "endpoint": self._serialize.url("self._config.endpoint", self._config.endpoint, "str", skip_quote=True), + } + _request.url = self._client.format_url(_request.url, **path_format_arguments) + + _decompress = kwargs.pop("decompress", True) + _stream = kwargs.pop("stream", False) + pipeline_response: PipelineResponse = self._client._pipeline.run( # pylint: disable=protected-access + _request, stream=_stream, **kwargs + ) + + response = pipeline_response.http_response + + if response.status_code not in [200]: + if _stream: + try: + response.read() # Load the body in memory and close the socket + except (StreamConsumedError, StreamClosedError): + pass + map_error(status_code=response.status_code, response=response, error_map=error_map) + raise HttpResponseError(response=response) + + if _stream: + deserialized = response.iter_bytes() if _decompress else response.iter_raw() + else: + deserialized = _deserialize(_models.ProviderStatus, response.json()) + + if cls: + return cls(pipeline_response, deserialized, {}) # type: ignore + + return deserialized # type: ignore diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_patch.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_patch.py index 87676c65a8f..ea765788358 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_patch.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_patch.py @@ -8,7 +8,6 @@ Follow our quickstart for examples: https://aka.ms/azsdk/python/dpcodegen/python/customize """ - __all__: list[str] = [] # Add all objects you want publicly available to users at this package level diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/py.typed b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/py.typed new file mode 100644 index 00000000000..e5aff4f83af --- /dev/null +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561. \ No newline at end of file diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/types.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/types.py new file mode 100644 index 00000000000..fc304f33bec --- /dev/null +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/types.py @@ -0,0 +1,431 @@ +# coding=utf-8 +# -------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# Code generated by Microsoft (R) Python Code Generator. +# Changes may cause incorrect behavior and will be lost if the code is regenerated. +# -------------------------------------------------------------------------- + +from typing import Any, Literal, TYPE_CHECKING, Union +from typing_extensions import Required, TypedDict + +from azure.core.exceptions import ODataV4Format + +from .models._enums import ItemType + +if TYPE_CHECKING: + from .models import CreatedByType, JobStatus, JobType, Priority, SessionJobFailurePolicy, SessionStatus + + +class BlobDetails(TypedDict, total=False): + """The details (name and container) of the blob to store or download data. + + :ivar containerName: The container name. Required. + :vartype containerName: str + :ivar blobName: The blob name. + :vartype blobName: str + """ + + containerName: Required[str] + """The container name. Required.""" + blobName: str + """The blob name.""" + + +class CostEstimate(TypedDict, total=False): + """The job cost billed by the provider. The final cost on your bill might be slightly different + due to added taxes and currency conversion rates. + + :ivar currencyCode: The currency code. Required. + :vartype currencyCode: str + :ivar events: List of usage events. + :vartype events: list["UsageEvent"] + :ivar estimatedTotal: The estimated total. Required. + :vartype estimatedTotal: float + """ + + currencyCode: Required[str] + """The currency code. Required.""" + events: list["UsageEvent"] + """List of usage events.""" + estimatedTotal: Required[float] + """The estimated total. Required.""" + + +class InnerError(TypedDict, total=False): + """An object containing more specific information about the error. As per Azure REST API + guidelines - `https://aka.ms/AzureRestApiGuidelines#handling-errors + `_. + + :ivar code: One of a server-defined set of error codes. + :vartype code: str + :ivar innererror: Inner error. + :vartype innererror: "InnerError" + """ + + code: str + """One of a server-defined set of error codes.""" + innererror: "InnerError" + """Inner error.""" + + +class JobDetails(TypedDict, total=False): + """A job to be run in the workspace. + + :ivar name: The name of the item. It is not required for the name to be unique and it's only + used for display purposes. Required. + :vartype name: str + :ivar providerId: The unique identifier for the provider. Required. + :vartype providerId: str + :ivar target: The target identifier to run the job. Required. + :vartype target: str + :ivar creationTime: The creation time of the item. + :vartype creationTime: str + :ivar createdBy: The identity that created the item. + :vartype createdBy: str + :ivar createdByType: The type of identity that created the item. Known values are: "User", + "Application", "ManagedIdentity", and "Key". + :vartype createdByType: Union[str, "CreatedByType"] + :ivar lastModifiedTime: The timestamp of the item last modification initiated by the customer. + :vartype lastModifiedTime: str + :ivar lastModifiedBy: The identity that last modified the item. + :vartype lastModifiedBy: str + :ivar lastModifiedByType: The type of identity that last modified the item. Known values are: + "User", "Application", "ManagedIdentity", and "Key". + :vartype lastModifiedByType: Union[str, "CreatedByType"] + :ivar lastUpdatedTime: The last time the item was updated by the system. + :vartype lastUpdatedTime: str + :ivar beginExecutionTime: The time when the item began execution. + :vartype beginExecutionTime: str + :ivar endExecutionTime: The time when the item finished execution. + :vartype endExecutionTime: str + :ivar costEstimate: Cost estimate. + :vartype costEstimate: "CostEstimate" + :ivar errorData: Error information. + :vartype errorData: "WorkspaceItemError" + :ivar priority: Priority of job or session. Known values are: "Standard" and "High". + :vartype priority: Union[str, "Priority"] + :ivar tags: List of user-supplied tags associated with the job. + :vartype tags: list[str] + :ivar usage: Resource consumption metrics containing provider-specific usage data such as + execution time, quantum shots consumed etc. + :vartype usage: "Usage" + :ivar id: Id of the job. Required. + :vartype id: str + :ivar itemType: Type of the Quantum Workspace item is Job. Required. A program, problem, or + application submitted for processing. + :vartype itemType: Literal[ItemType.JOB] + :ivar jobType: The type of job. Known values are: "Unknown", "QuantumComputing", and + "Optimization". + :vartype jobType: Union[str, "JobType"] + :ivar sessionId: The ID of the session that the job is part of. + :vartype sessionId: str + :ivar containerUri: The blob container SAS uri, the container is used to host job data. + Required. + :vartype containerUri: str + :ivar inputDataUri: The input blob URI, if specified, it will override the default input blob + in the container. + :vartype inputDataUri: str + :ivar inputDataFormat: The format of the input data. + :vartype inputDataFormat: str + :ivar status: The status of the job. Known values are: "Queued", "Waiting", "Executing", + "CancellationRequested", "Cancelling", "Finishing", "Completed", "Succeeded", "Failed", and + "Cancelled". + :vartype status: Union[str, "JobStatus"] + :ivar metadata: The job metadata. Metadata provides client the ability to store client-specific + information. + :vartype metadata: Any + :ivar cancellationTime: The time when a job was successfully cancelled. + :vartype cancellationTime: str + :ivar quantumComputingData: Quantum computing data. + :vartype quantumComputingData: "QuantumComputingData" + :ivar inputParams: The input parameters for the job. JSON object used by the target solver. It + is expected that the size of this object is small and only used to specify parameters for the + execution target, not the input data. + :vartype inputParams: Any + :ivar outputDataUri: The output blob uri. When a job finishes successfully, results will be + uploaded to this blob. + :vartype outputDataUri: str + :ivar outputDataFormat: The format of the output data. + :vartype outputDataFormat: str + """ + + name: Required[str] + """The name of the item. It is not required for the name to be unique and it's only used for + display purposes. Required.""" + providerId: Required[str] + """The unique identifier for the provider. Required.""" + target: Required[str] + """The target identifier to run the job. Required.""" + creationTime: str + """The creation time of the item.""" + createdBy: str + """The identity that created the item.""" + createdByType: Union[str, "CreatedByType"] + """The type of identity that created the item. Known values are: \"User\", \"Application\", + \"ManagedIdentity\", and \"Key\".""" + lastModifiedTime: str + """The timestamp of the item last modification initiated by the customer.""" + lastModifiedBy: str + """The identity that last modified the item.""" + lastModifiedByType: Union[str, "CreatedByType"] + """The type of identity that last modified the item. Known values are: \"User\", \"Application\", + \"ManagedIdentity\", and \"Key\".""" + lastUpdatedTime: str + """The last time the item was updated by the system.""" + beginExecutionTime: str + """The time when the item began execution.""" + endExecutionTime: str + """The time when the item finished execution.""" + costEstimate: "CostEstimate" + """Cost estimate.""" + errorData: "WorkspaceItemError" + """Error information.""" + priority: Union[str, "Priority"] + """Priority of job or session. Known values are: \"Standard\" and \"High\".""" + tags: list[str] + """List of user-supplied tags associated with the job.""" + usage: "Usage" + """Resource consumption metrics containing provider-specific usage data such as execution time, + quantum shots consumed etc.""" + id: Required[str] + """Id of the job. Required.""" + itemType: Required[Literal[ItemType.JOB]] + """Type of the Quantum Workspace item is Job. Required. A program, problem, or application + submitted for processing.""" + jobType: Union[str, "JobType"] + """The type of job. Known values are: \"Unknown\", \"QuantumComputing\", and \"Optimization\".""" + sessionId: str + """The ID of the session that the job is part of.""" + containerUri: Required[str] + """The blob container SAS uri, the container is used to host job data. Required.""" + inputDataUri: str + """The input blob URI, if specified, it will override the default input blob in the container.""" + inputDataFormat: str + """The format of the input data.""" + status: Union[str, "JobStatus"] + """The status of the job. Known values are: \"Queued\", \"Waiting\", \"Executing\", + \"CancellationRequested\", \"Cancelling\", \"Finishing\", \"Completed\", \"Succeeded\", + \"Failed\", and \"Cancelled\".""" + metadata: Any + """The job metadata. Metadata provides client the ability to store client-specific information.""" + cancellationTime: str + """The time when a job was successfully cancelled.""" + quantumComputingData: "QuantumComputingData" + """Quantum computing data.""" + inputParams: Any + """The input parameters for the job. JSON object used by the target solver. It is expected that + the size of this object is small and only used to specify parameters for the execution target, + not the input data.""" + outputDataUri: str + """The output blob uri. When a job finishes successfully, results will be uploaded to this blob.""" + outputDataFormat: str + """The format of the output data.""" + + +class JobUpdateOptions(TypedDict, total=False): + """Options for updating a job. + + :ivar id: Id of the job. Required. + :vartype id: str + :ivar priority: Priority of job. Known values are: "Standard" and "High". + :vartype priority: Union[str, "Priority"] + :ivar name: The name of the job. + :vartype name: str + :ivar tags: List of user-supplied tags associated with the job. + :vartype tags: list[str] + """ + + id: Required[str] + """Id of the job. Required.""" + priority: Union[str, "Priority"] + """Priority of job. Known values are: \"Standard\" and \"High\".""" + name: str + """The name of the job.""" + tags: list[str] + """List of user-supplied tags associated with the job.""" + + +class QuantumComputingData(TypedDict, total=False): + """Quantum computing data. + + :ivar count: The number of quantum computing items in the job. Required. + :vartype count: int + """ + + count: Required[int] + """The number of quantum computing items in the job. Required.""" + + +class SessionDetails(TypedDict, total=False): + """Session, a logical grouping of jobs. + + :ivar name: The name of the item. It is not required for the name to be unique and it's only + used for display purposes. Required. + :vartype name: str + :ivar providerId: The unique identifier for the provider. Required. + :vartype providerId: str + :ivar target: The target identifier to run the job. Required. + :vartype target: str + :ivar creationTime: The creation time of the item. + :vartype creationTime: str + :ivar createdBy: The identity that created the item. + :vartype createdBy: str + :ivar createdByType: The type of identity that created the item. Known values are: "User", + "Application", "ManagedIdentity", and "Key". + :vartype createdByType: Union[str, "CreatedByType"] + :ivar lastModifiedTime: The timestamp of the item last modification initiated by the customer. + :vartype lastModifiedTime: str + :ivar lastModifiedBy: The identity that last modified the item. + :vartype lastModifiedBy: str + :ivar lastModifiedByType: The type of identity that last modified the item. Known values are: + "User", "Application", "ManagedIdentity", and "Key". + :vartype lastModifiedByType: Union[str, "CreatedByType"] + :ivar lastUpdatedTime: The last time the item was updated by the system. + :vartype lastUpdatedTime: str + :ivar beginExecutionTime: The time when the item began execution. + :vartype beginExecutionTime: str + :ivar endExecutionTime: The time when the item finished execution. + :vartype endExecutionTime: str + :ivar costEstimate: Cost estimate. + :vartype costEstimate: "CostEstimate" + :ivar errorData: Error information. + :vartype errorData: "WorkspaceItemError" + :ivar priority: Priority of job or session. Known values are: "Standard" and "High". + :vartype priority: Union[str, "Priority"] + :ivar tags: List of user-supplied tags associated with the job. + :vartype tags: list[str] + :ivar usage: Resource consumption metrics containing provider-specific usage data such as + execution time, quantum shots consumed etc. + :vartype usage: "Usage" + :ivar id: Id of the session. Required. + :vartype id: str + :ivar itemType: Type of the Quantum Workspace item is Session. Required. A logical grouping of + jobs. + :vartype itemType: Literal[ItemType.SESSION] + :ivar jobFailurePolicy: Policy controlling the behavior of the Session when a job in the + session fails. Required. Known values are: "Abort" and "Continue". + :vartype jobFailurePolicy: Union[str, "SessionJobFailurePolicy"] + :ivar status: The status of the session. Known values are: "Waiting", "Executing", "Succeeded", + "Failed", "Failure(s)", and "TimedOut". + :vartype status: Union[str, "SessionStatus"] + """ + + name: Required[str] + """The name of the item. It is not required for the name to be unique and it's only used for + display purposes. Required.""" + providerId: Required[str] + """The unique identifier for the provider. Required.""" + target: Required[str] + """The target identifier to run the job. Required.""" + creationTime: str + """The creation time of the item.""" + createdBy: str + """The identity that created the item.""" + createdByType: Union[str, "CreatedByType"] + """The type of identity that created the item. Known values are: \"User\", \"Application\", + \"ManagedIdentity\", and \"Key\".""" + lastModifiedTime: str + """The timestamp of the item last modification initiated by the customer.""" + lastModifiedBy: str + """The identity that last modified the item.""" + lastModifiedByType: Union[str, "CreatedByType"] + """The type of identity that last modified the item. Known values are: \"User\", \"Application\", + \"ManagedIdentity\", and \"Key\".""" + lastUpdatedTime: str + """The last time the item was updated by the system.""" + beginExecutionTime: str + """The time when the item began execution.""" + endExecutionTime: str + """The time when the item finished execution.""" + costEstimate: "CostEstimate" + """Cost estimate.""" + errorData: "WorkspaceItemError" + """Error information.""" + priority: Union[str, "Priority"] + """Priority of job or session. Known values are: \"Standard\" and \"High\".""" + tags: list[str] + """List of user-supplied tags associated with the job.""" + usage: "Usage" + """Resource consumption metrics containing provider-specific usage data such as execution time, + quantum shots consumed etc.""" + id: Required[str] + """Id of the session. Required.""" + itemType: Required[Literal[ItemType.SESSION]] + """Type of the Quantum Workspace item is Session. Required. A logical grouping of jobs.""" + jobFailurePolicy: Required[Union[str, "SessionJobFailurePolicy"]] + """Policy controlling the behavior of the Session when a job in the session fails. Required. Known + values are: \"Abort\" and \"Continue\".""" + status: Union[str, "SessionStatus"] + """The status of the session. Known values are: \"Waiting\", \"Executing\", \"Succeeded\", + \"Failed\", \"Failure(s)\", and \"TimedOut\".""" + + +class Usage(TypedDict, total=False): + """Resource usage metrics represented as key-value pairs. Keys are provider-defined metric names + (e.g. "standardMinutes", "shots") and values are the corresponding consumption amounts. The + specific metrics available depend on the quantum provider and target used. + + """ + + +class UsageEvent(TypedDict, total=False): + """Usage event details. + + :ivar dimensionId: The dimension id. Required. + :vartype dimensionId: str + :ivar dimensionName: The dimension name. Required. + :vartype dimensionName: str + :ivar measureUnit: The unit of measure. Required. + :vartype measureUnit: str + :ivar amountBilled: The amount billed. Required. + :vartype amountBilled: float + :ivar amountConsumed: The amount consumed. Required. + :vartype amountConsumed: float + :ivar unitPrice: The unit price. Required. + :vartype unitPrice: float + """ + + dimensionId: Required[str] + """The dimension id. Required.""" + dimensionName: Required[str] + """The dimension name. Required.""" + measureUnit: Required[str] + """The unit of measure. Required.""" + amountBilled: Required[float] + """The amount billed. Required.""" + amountConsumed: Required[float] + """The amount consumed. Required.""" + unitPrice: Required[float] + """The unit price. Required.""" + + +class WorkspaceItemError(TypedDict, total=False): + """The error object. + + :ivar code: One of a server-defined set of error codes. Required. + :vartype code: str + :ivar message: A human-readable representation of the error. Required. + :vartype message: str + :ivar target: The target of the error. + :vartype target: str + :ivar details: An array of details about specific errors that led to this reported error. + :vartype details: list[ODataV4Format] + :ivar innererror: An object containing more specific information than the current object about + the error. + :vartype innererror: "InnerError" + """ + + code: Required[str] + """One of a server-defined set of error codes. Required.""" + message: Required[str] + """A human-readable representation of the error. Required.""" + target: str + """The target of the error.""" + details: list[ODataV4Format] + """An array of details about specific errors that led to this reported error.""" + innererror: "InnerError" + """An object containing more specific information than the current object about the error.""" + + +ItemDetails = Union[JobDetails, SessionDetails] diff --git a/src/quantum/setup.py b/src/quantum/setup.py index bbac3ca69e6..0ce47c0a85d 100644 --- a/src/quantum/setup.py +++ b/src/quantum/setup.py @@ -49,5 +49,8 @@ license='MIT', classifiers=CLASSIFIERS, packages=find_packages(), - package_data={'azext_quantum': ['azext_metadata.json', 'operations/templates/create-workspace-and-assign-role.json']}, + package_data={ + 'azext_quantum': ['azext_metadata.json', 'operations/templates/create-workspace-and-assign-role.json'], + 'azext_quantum.vendored_sdks.azure_quantum_python._client': ['py.typed'], + }, ) From abd0c2690488df293e498a494e2c363266363cd0 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Wed, 9 Sep 2026 09:27:02 -0700 Subject: [PATCH 18/24] [Quantum] Sync quota usage model naming --- .../tests/latest/test_quantum_suite_offers.py | 6 +++--- .../_client/aio/operations/_operations.py | 20 +++++++++---------- .../_client/models/__init__.py | 4 ++-- .../_client/models/_models.py | 14 ++++++++----- .../_client/operations/_operations.py | 20 +++++++++---------- 5 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index 8e336c31b3e..ba5bf93a267 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -11,7 +11,7 @@ from ...commands import transform_suite_offers, transform_suite_offer_quotas, transform_suite_offer_targets from ..._client_factory import base_url_v2 from ...operations.suite_offers import _merge_suite_offer_quotas -from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsageData, ProviderStatus +from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsage, ProviderStatus from ...vendored_sdks.azure_quantum_python._client._utils.model_base import _deserialize from ...vendored_sdks.azure_quantum_python._client.operations._operations import ( build_services_suite_offers_list_quota_usages_request, @@ -141,7 +141,7 @@ def test_deserialize_quota_usages(self): }, ] - usages = _deserialize(list[QuotaUsageData], data) + usages = _deserialize(list[QuotaUsage], data) self.assertEqual(len(usages), 2) self.assertEqual(usages[0].scope, 'Subscription') @@ -270,7 +270,7 @@ def test_merge_quotas_target_with_usage(self): ) usages = [ _usage(target_id=None, standard=40, high=10), # subscription-scope usage ignored - _deserialize(QuotaUsageData, { + _deserialize(QuotaUsage, { 'providerId': 'ionq', 'scope': 'SubscriptionTarget', 'targetId': 'ionq.qpu', diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py index e4cc9c5a748..6f478e5b341 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py @@ -1251,7 +1251,7 @@ async def get_next(next_link=None): ) def list_workspace_usages( self, subscription_id: str, resource_group_name: str, workspace_name: str, *, provider_id: str, **kwargs: Any - ) -> AsyncItemPaged["_models.QuotaUsageData"]: + ) -> AsyncItemPaged["_models.QuotaUsage"]: """List quota usages for the given workspace. This operation is only available for v2 workspaces. :param subscription_id: The Azure subscription ID. Required. @@ -1262,14 +1262,14 @@ def list_workspace_usages( :type workspace_name: str :keyword provider_id: The unique identifier for the provider to get quota usages for. Required. :paramtype provider_id: str - :return: An iterator like instance of QuotaUsageData - :rtype: ~azure.core.async_paging.AsyncItemPaged[~azure.quantum.models.QuotaUsageData] + :return: An iterator like instance of QuotaUsage + :rtype: ~azure.core.async_paging.AsyncItemPaged[~azure.quantum.models.QuotaUsage] :raises ~azure.core.exceptions.HttpResponseError: """ _headers = kwargs.pop("headers", {}) or {} _params = kwargs.pop("params", {}) or {} - cls: ClsType[list[_models.QuotaUsageData]] = kwargs.pop("cls", None) + cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) error_map: MutableMapping = { 401: ClientAuthenticationError, @@ -1326,7 +1326,7 @@ def prepare_request(next_link=None): async def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() list_of_elem = _deserialize( - list[_models.QuotaUsageData], + list[_models.QuotaUsage], deserialized.get("value", []), ) if cls: @@ -2202,21 +2202,21 @@ def __init__(self, *args, **kwargs) -> None: ) def list_quota_usages( self, subscription_id: str, provider_id: str, **kwargs: Any - ) -> AsyncItemPaged["_models.QuotaUsageData"]: + ) -> AsyncItemPaged["_models.QuotaUsage"]: """List quota usages for the given suite offer provider in the subscription. :param subscription_id: The Azure subscription ID. Required. :type subscription_id: str :param provider_id: The unique identifier for the provider. Required. :type provider_id: str - :return: An iterator like instance of QuotaUsageData - :rtype: ~azure.core.async_paging.AsyncItemPaged[~azure.quantum.models.QuotaUsageData] + :return: An iterator like instance of QuotaUsage + :rtype: ~azure.core.async_paging.AsyncItemPaged[~azure.quantum.models.QuotaUsage] :raises ~azure.core.exceptions.HttpResponseError: """ _headers = kwargs.pop("headers", {}) or {} _params = kwargs.pop("params", {}) or {} - cls: ClsType[list[_models.QuotaUsageData]] = kwargs.pop("cls", None) + cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) error_map: MutableMapping = { 401: ClientAuthenticationError, @@ -2271,7 +2271,7 @@ def prepare_request(next_link=None): async def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() list_of_elem = _deserialize( - list[_models.QuotaUsageData], + list[_models.QuotaUsage], deserialized.get("value", []), ) if cls: diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py index b4828b895f0..b78214484a5 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/__init__.py @@ -24,7 +24,7 @@ ProviderStatus, QuantumComputingData, Quota, - QuotaUsageData, + QuotaUsage, SasUriResponse, SessionDetails, TargetStatus, @@ -62,7 +62,7 @@ "ProviderStatus", "QuantumComputingData", "Quota", - "QuotaUsageData", + "QuotaUsage", "SasUriResponse", "SessionDetails", "TargetStatus", diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py index 4d31168a3ca..9c69ce89c38 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/models/_models.py @@ -571,8 +571,8 @@ class Quota(_Model): 'None' is used for concurrent quotas. Required. Known values are: \"None\" and \"Monthly\".""" -class QuotaUsageData(_Model): - """Quota usage data for a suite offer provider. +class QuotaUsage(_Model): + """Quota usage for a suite offer provider. :ivar provider_id: The unique identifier for the provider. Required. :vartype provider_id: str @@ -718,9 +718,11 @@ class TargetStatus(_Model): :vartype current_availability: str or ~azure.quantum.models.TargetAvailability :ivar average_queue_time: Average queue time in seconds. Required. :vartype average_queue_time: int - :ivar average_queue_time_high_priority: Average high-priority queue time in seconds. + :ivar average_queue_time_high_priority: Average high-priority queue time in seconds. Only + populated for v2 workspaces and suite offers; omitted otherwise. :vartype average_queue_time_high_priority: int :ivar average_queue_time_standard_priority: Average standard-priority queue time in seconds. + Only populated for v2 workspaces and suite offers; omitted otherwise. :vartype average_queue_time_standard_priority: int :ivar status_page: A page with detailed status of the provider. :vartype status_page: str @@ -744,11 +746,13 @@ class TargetStatus(_Model): average_queue_time_high_priority: Optional[int] = rest_field( name="averageQueueTimeHighPriority", visibility=["read"] ) - """Average high-priority queue time in seconds.""" + """Average high-priority queue time in seconds. Only populated for v2 workspaces and suite offers; + omitted otherwise.""" average_queue_time_standard_priority: Optional[int] = rest_field( name="averageQueueTimeStandardPriority", visibility=["read"] ) - """Average standard-priority queue time in seconds.""" + """Average standard-priority queue time in seconds. Only populated for v2 workspaces and suite + offers; omitted otherwise.""" status_page: Optional[str] = rest_field(name="statusPage", visibility=["read"]) """A page with detailed status of the provider.""" num_qubits: Optional[int] = rest_field(name="numQubits", visibility=["read"]) diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py index 1aa81764d80..e9eed057a58 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py @@ -1816,7 +1816,7 @@ def get_next(next_link=None): ) def list_workspace_usages( self, subscription_id: str, resource_group_name: str, workspace_name: str, *, provider_id: str, **kwargs: Any - ) -> ItemPaged["_models.QuotaUsageData"]: + ) -> ItemPaged["_models.QuotaUsage"]: """List quota usages for the given workspace. This operation is only available for v2 workspaces. :param subscription_id: The Azure subscription ID. Required. @@ -1827,14 +1827,14 @@ def list_workspace_usages( :type workspace_name: str :keyword provider_id: The unique identifier for the provider to get quota usages for. Required. :paramtype provider_id: str - :return: An iterator like instance of QuotaUsageData - :rtype: ~azure.core.paging.ItemPaged[~azure.quantum.models.QuotaUsageData] + :return: An iterator like instance of QuotaUsage + :rtype: ~azure.core.paging.ItemPaged[~azure.quantum.models.QuotaUsage] :raises ~azure.core.exceptions.HttpResponseError: """ _headers = kwargs.pop("headers", {}) or {} _params = kwargs.pop("params", {}) or {} - cls: ClsType[list[_models.QuotaUsageData]] = kwargs.pop("cls", None) + cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) error_map: MutableMapping = { 401: ClientAuthenticationError, @@ -1891,7 +1891,7 @@ def prepare_request(next_link=None): def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() list_of_elem = _deserialize( - list[_models.QuotaUsageData], + list[_models.QuotaUsage], deserialized.get("value", []), ) if cls: @@ -2767,21 +2767,21 @@ def __init__(self, *args, **kwargs) -> None: ) def list_quota_usages( self, subscription_id: str, provider_id: str, **kwargs: Any - ) -> ItemPaged["_models.QuotaUsageData"]: + ) -> ItemPaged["_models.QuotaUsage"]: """List quota usages for the given suite offer provider in the subscription. :param subscription_id: The Azure subscription ID. Required. :type subscription_id: str :param provider_id: The unique identifier for the provider. Required. :type provider_id: str - :return: An iterator like instance of QuotaUsageData - :rtype: ~azure.core.paging.ItemPaged[~azure.quantum.models.QuotaUsageData] + :return: An iterator like instance of QuotaUsage + :rtype: ~azure.core.paging.ItemPaged[~azure.quantum.models.QuotaUsage] :raises ~azure.core.exceptions.HttpResponseError: """ _headers = kwargs.pop("headers", {}) or {} _params = kwargs.pop("params", {}) or {} - cls: ClsType[list[_models.QuotaUsageData]] = kwargs.pop("cls", None) + cls: ClsType[list[_models.QuotaUsage]] = kwargs.pop("cls", None) error_map: MutableMapping = { 401: ClientAuthenticationError, @@ -2836,7 +2836,7 @@ def prepare_request(next_link=None): def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() list_of_elem = _deserialize( - list[_models.QuotaUsageData], + list[_models.QuotaUsage], deserialized.get("value", []), ) if cls: From 1627d733db13c0fc7254f9b7961ff9d5e8183ec8 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Wed, 9 Sep 2026 14:32:07 -0700 Subject: [PATCH 19/24] [Quantum] Show zero for missing suite quota usage --- src/quantum/HISTORY.rst | 1 + src/quantum/azext_quantum/_help.py | 2 +- .../azext_quantum/operations/suite_offers.py | 8 +++++--- .../tests/latest/test_quantum_suite_offers.py | 14 ++++++++++++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index fa416e1ca41..f3e436e3051 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -7,6 +7,7 @@ Release History ++++++++++++++ * Added the ``az quantum suite-offer target list`` command to list the targets, availability, and overall, Standard, and High average queue times available through a suite offer provider account, without requiring a workspace. * Removed the redundant provider column from the ``az quantum suite-offer target list`` table output. +* Updated ``az quantum suite-offer quotas`` to return ``0`` for missing Standard and High usage values. * Updated the ``az quantum workspace quotas`` command to include v2 target quota allocations and usages while preserving the existing response format for v1 providers. * Added always-on validation for V2 workspace target quota allocations on create and update, allowing final Standard and High values between current workspace usage and suite target allocation, inclusive. diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index 2f94b3e9693..ed3fb851ce8 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -239,7 +239,7 @@ long-summary: | Returns the v2 quota allocations (limits) for each target of the provider account together with the consumed usages. Each entry reports the allocated and used standard and high priority - minutes over the lifetime of the provider account. + minutes over the lifetime of the provider account. Missing usage values are returned as 0. examples: - name: View the quota usages for a suite offer provider account. text: |- diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py index 70f410c85ed..b4c40582f90 100644 --- a/src/quantum/azext_quantum/operations/suite_offers.py +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -23,10 +23,12 @@ def _quota_usage_value(usage, attribute): if usage is None: - return None + return 0 if hasattr(usage, "get"): - return usage.get(_QUOTA_USAGE_FIELDS[attribute]) - return getattr(usage, attribute, None) + value = usage.get(_QUOTA_USAGE_FIELDS[attribute]) + else: + value = getattr(usage, attribute, None) + return value if value is not None else 0 def list_suite_offers(cmd): diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index ba5bf93a267..73fdb7eefb8 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -300,7 +300,17 @@ def test_merge_quotas_target_without_usage(self): self.assertEqual(len(rows), 1) row = rows[0] self.assertEqual(row['allocation'], {'standardMinutesLifetime': 30, 'highMinutesLifetime': None}) - self.assertEqual(row['usage'], {'standardMinutesLifetime': None, 'highMinutesLifetime': None}) + self.assertEqual(row['usage'], {'standardMinutesLifetime': 0, 'highMinutesLifetime': 0}) + + def test_merge_quotas_null_usage_values_as_zero(self): + offer = _offer( + target_quotas=[_allocation(standard=30, high=15, target_id='ionq.qpu')], + ) + usages = [_usage(target_id='ionq.qpu', standard=None, high=None)] + + rows = _merge_suite_offer_quotas(offer, usages, 'ionq') + + self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': 0, 'highMinutesLifetime': 0}) def test_merge_quotas_ignores_subscription_and_unmatched_usage(self): offer = _offer( @@ -316,7 +326,7 @@ def test_merge_quotas_ignores_subscription_and_unmatched_usage(self): self.assertEqual(len(rows), 1) self.assertEqual(rows[0]['targetId'], 'ionq.qpu') - self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': None, 'highMinutesLifetime': None}) + self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': 0, 'highMinutesLifetime': 0}) @live_only() def test_quantum_suite_offer_list(self): From 8ee3abe3206ea1a18d30de41ec22c75f8393b85a Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Wed, 9 Sep 2026 16:41:17 -0700 Subject: [PATCH 20/24] [Quantum] Align suite quota table values --- src/quantum/azext_quantum/commands.py | 3 ++- .../tests/latest/test_quantum_suite_offers.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 7a69cae6a0a..2a26f7133ba 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -119,7 +119,8 @@ def one(offer): def _quota_hours(minutes): """Convert lifetime quota minutes to hours (2 dp), matching the Quantum studio UI.""" - return 0 if minutes is None else round(minutes / 60, 2) + hours = 0 if minutes is None else minutes / 60 + return f"{hours:.2f}" def transform_suite_offer_quotas(quotas): diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index 73fdb7eefb8..b1a9fcea572 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -98,15 +98,15 @@ def test_transform_suite_offer_quotas(self): 'Target', 'Std Allocated (hrs)', 'Std Used (hrs)', 'High Allocated (hrs)', 'High Used (hrs)' ]) self.assertEqual(row['Target'], 'ionq.qpu') - self.assertEqual(row['Std Allocated (hrs)'], 1.67) - self.assertEqual(row['Std Used (hrs)'], 0.67) - self.assertEqual(row['High Allocated (hrs)'], 0.83) - self.assertEqual(row['High Used (hrs)'], 0.17) + self.assertEqual(row['Std Allocated (hrs)'], '1.67') + self.assertEqual(row['Std Used (hrs)'], '0.67') + self.assertEqual(row['High Allocated (hrs)'], '0.83') + self.assertEqual(row['High Used (hrs)'], '0.17') missing_row = table[1] - self.assertEqual(missing_row['Std Allocated (hrs)'], 0) - self.assertEqual(missing_row['Std Used (hrs)'], 0) - self.assertEqual(missing_row['High Allocated (hrs)'], 0) - self.assertEqual(missing_row['High Used (hrs)'], 0) + self.assertEqual(missing_row['Std Allocated (hrs)'], '0.00') + self.assertEqual(missing_row['Std Used (hrs)'], '0.00') + self.assertEqual(missing_row['High Allocated (hrs)'], '0.00') + self.assertEqual(missing_row['High Used (hrs)'], '0.00') def test_base_url_v2(self): self.assertEqual(base_url_v2('East US'), 'https://eastus-v2.quantum.azure.com/') From ce2a4644522b2d6043fbc3a728fb4cebb01d9664 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Thu, 10 Sep 2026 11:23:27 -0700 Subject: [PATCH 21/24] [Quantum] Route workspace quotas by workspace kind --- .../azext_quantum/operations/workspace.py | 19 ++--- .../tests/latest/test_quantum_workspace.py | 74 +++++++++---------- 2 files changed, 46 insertions(+), 47 deletions(-) diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 1ec3fcde079..7ec682b9365 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -23,7 +23,7 @@ MutuallyExclusiveArgumentError) from azure.core.exceptions import ResourceNotFoundError as AzureResourceNotFoundError -from .._client_factory import cf_workspaces, cf_quotas, cf_offerings, cf_suite_offers, _get_data_credentials, base_url, base_url_v2 +from .._client_factory import cf_workspaces, cf_quotas, cf_offerings, cf_suite_offers, _get_data_credentials from .._list_helper import repack_response_json from ..vendored_sdks.azure_mgmt_quantum.models import QuantumWorkspace from ..vendored_sdks.azure_mgmt_quantum.models import ManagedServiceIdentity @@ -342,7 +342,7 @@ def _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage): usage_by_key = {} if include_usage: usage_client = cf_quotas( - cmd.cli_ctx, info.subscription, info.resource_group, info.name, base_url_v2(workspace.location)) + cmd.cli_ctx, info.subscription, info.resource_group, info.name, workspace.properties.endpoint_uri) for provider_id in sorted({provider_id for provider_id, _ in requested_keys}): provider = workspace_providers[provider_id] try: @@ -562,17 +562,13 @@ def quotas(cmd, resource_group_name, workspace_name): workspace = cf_workspaces(cmd.cli_ctx).get(info.resource_group, info.name) properties = workspace.properties providers = properties.providers if properties is not None else None - - legacy_client = cf_quotas( - cmd.cli_ctx, info.subscription, info.resource_group, info.name, base_url(workspace.location)) - legacy_quotas = repack_response_json( - legacy_client.list(info.subscription, info.resource_group, info.name)) - + endpoint = properties.endpoint_uri if properties is not None else info.endpoint usages = [] + legacy_quotas = [] workspace_kind = getattr(properties, 'workspace_kind', None) if properties is not None else None if str(_enum_to_value(workspace_kind)).upper() == 'V2': v2_client = cf_quotas( - cmd.cli_ctx, info.subscription, info.resource_group, info.name, base_url_v2(workspace.location)) + cmd.cli_ctx, info.subscription, info.resource_group, info.name, endpoint) for provider in providers or []: try: provider_usages = v2_client.list_workspace_usages( @@ -580,6 +576,11 @@ def quotas(cmd, resource_group_name, workspace_name): except AzureResourceNotFoundError: provider_usages = None usages.extend(provider_usages or []) + else: + legacy_client = cf_quotas( + cmd.cli_ctx, info.subscription, info.resource_group, info.name, endpoint) + legacy_quotas = repack_response_json( + legacy_client.list(info.subscription, info.resource_group, info.name)) return _merge_workspace_quotas(workspace, usages, legacy_quotas) diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 691b590ab60..47589b65242 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -588,7 +588,8 @@ def test_target_quota_bounds_allow_equality_and_match_case_insensitively(self): provider = Provider(provider_id='Provider', target_quotas=[TargetQuotaAllocations( target_id='Provider.Target', standard_minutes_lifetime=25, high_minutes_lifetime=100 )]) - workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace( + endpoint_uri='https://workspace.eastus-v2.quantum.azure.com/', providers=[provider])) suite_offer = SimpleNamespace(properties=SimpleNamespace( provider_id='PROVIDER', target_quotas=[TargetQuotaAllocations( target_id='PROVIDER.TARGET', standard_minutes_lifetime=100, high_minutes_lifetime=100 @@ -625,7 +626,8 @@ def test_target_quota_bounds_reject_values_outside_inclusive_range(self): provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( target_id='provider.target', standard_minutes_lifetime=final_value )]) - workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace( + endpoint_uri='https://workspace.eastus-v2.quantum.azure.com/', providers=[provider])) suite_offer = SimpleNamespace(properties=SimpleNamespace( provider_id='provider', target_quotas=[TargetQuotaAllocations( target_id='provider.target', standard_minutes_lifetime=100 @@ -651,7 +653,8 @@ def test_target_quota_bounds_validate_high_priority_independently(self): provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( target_id='provider.target', standard_minutes_lifetime=50, high_minutes_lifetime=21 )]) - workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace( + endpoint_uri='https://workspace.eastus-v2.quantum.azure.com/', providers=[provider])) suite_offer = SimpleNamespace(properties=SimpleNamespace( provider_id='provider', target_quotas=[TargetQuotaAllocations( target_id='provider.target', standard_minutes_lifetime=100, high_minutes_lifetime=20 @@ -710,7 +713,8 @@ def test_target_quota_bounds_query_usage_once_per_provider(self): TargetQuotaAllocations(target_id='provider.target-1', standard_minutes_lifetime=50), TargetQuotaAllocations(target_id='provider.target-2', standard_minutes_lifetime=50), ]) - workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace( + endpoint_uri='https://workspace.eastus-v2.quantum.azure.com/', providers=[provider])) suite_offer = SimpleNamespace(properties=SimpleNamespace( provider_id='provider', target_quotas=[ TargetQuotaAllocations(target_id='provider.target-1', standard_minutes_lifetime=100), @@ -738,7 +742,8 @@ def test_target_quota_bounds_treat_usage_404_as_zero(self): provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( target_id='provider.target', standard_minutes_lifetime=0 )]) - workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(providers=[provider])) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace( + endpoint_uri='https://workspace.eastus-v2.quantum.azure.com/', providers=[provider])) suite_offer = SimpleNamespace(properties=SimpleNamespace( provider_id='provider', target_quotas=[TargetQuotaAllocations( target_id='provider.target', standard_minutes_lifetime=100 @@ -1037,18 +1042,16 @@ def test_transform_workspace_quotas_preserves_mixed_dimensions(self): self.assertEqual(table[1]['Limit'], 1200) self.assertEqual(table[1]['Utilization'], 27.0) - def test_quotas_handler_queries_each_provider_and_merges(self): + def test_quotas_handler_queries_v2_usages_without_legacy_quotas(self): info = SimpleNamespace(subscription='sub', resource_group='rg', name='ws', endpoint=None) - workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace(workspace_kind='V2', providers=[ - SimpleNamespace(provider_id='ionq', target_quotas=[ - SimpleNamespace(target_id='ionq.qpu', standard_minutes_lifetime=30, high_minutes_lifetime=15), - ]), - SimpleNamespace(provider_id='pasqal', target_quotas=None), - ])) - legacy_row = { - 'dimension': 'emulator_hours', 'providerId': 'pasqal', 'scope': 'Subscription', - 'limit': 5.0, 'utilization': 1.0, 'holds': 0.0, 'period': 'Monthly' - } + endpoint = 'https://ws.eastus-v2.quantum.azure.com/' + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace( + workspace_kind='V2', endpoint_uri=endpoint, providers=[ + SimpleNamespace(provider_id='ionq', target_quotas=[ + SimpleNamespace(target_id='ionq.qpu', standard_minutes_lifetime=30, high_minutes_lifetime=15), + ]), + SimpleNamespace(provider_id='pasqal', target_quotas=None), + ])) usage_by_provider = { 'ionq': [SimpleNamespace(provider_id='ionq', target_id='ionq.qpu', @@ -1062,37 +1065,32 @@ def fake_list_workspace_usages(*args, **kwargs): queried.append(provider_id) return usage_by_provider[provider_id] - legacy_client = SimpleNamespace(list=lambda *_: [legacy_row]) v2_client = SimpleNamespace(list_workspace_usages=fake_list_workspace_usages) - def fake_client_factory(*args): - endpoint = args[-1] - return legacy_client if endpoint == 'https://eastus.quantum.azure.com/' else v2_client - from ...operations import workspace as workspace_ops + cli_ctx = object() with patch.object(workspace_ops, 'WorkspaceInfo', return_value=info), \ patch.object(workspace_ops, 'cf_workspaces', return_value=SimpleNamespace(get=lambda rg, ws: workspace)), \ - patch.object(workspace_ops, 'base_url', return_value='https://eastus.quantum.azure.com/'), \ - patch.object(workspace_ops, 'base_url_v2', return_value='https://eastus-v2.quantum.azure.com/'), \ - patch.object(workspace_ops, 'cf_quotas', side_effect=fake_client_factory) as client_factory: - cmd = SimpleNamespace(cli_ctx=object()) + patch.object(workspace_ops, 'cf_quotas', return_value=v2_client) as client_factory: + cmd = SimpleNamespace(cli_ctx=cli_ctx) rows = workspace_ops.quotas(cmd, 'rg', 'ws') self.assertEqual(set(queried), {'ionq', 'pasqal'}) - self.assertEqual(client_factory.call_count, 2) - self.assertEqual(len(rows), 3) - self.assertEqual(rows[0], legacy_row) - self.assertEqual(rows[1]['dimension'], 'StandardMinutesLifetime') - self.assertEqual(rows[1]['limit'], 30) - self.assertEqual(rows[1]['utilization'], 5) - self.assertEqual(rows[2]['dimension'], 'HighMinutesLifetime') - self.assertEqual(rows[2]['limit'], 15) - self.assertEqual(rows[2]['utilization'], 2) + client_factory.assert_called_once_with(cli_ctx, 'sub', 'rg', 'ws', endpoint) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]['dimension'], 'StandardMinutesLifetime') + self.assertEqual(rows[0]['limit'], 30) + self.assertEqual(rows[0]['utilization'], 5) + self.assertEqual(rows[1]['dimension'], 'HighMinutesLifetime') + self.assertEqual(rows[1]['limit'], 15) + self.assertEqual(rows[1]['utilization'], 2) def test_quotas_handler_keeps_v1_behavior_without_v2_usage_calls(self): info = SimpleNamespace(subscription='sub', resource_group='rg', name='ws', endpoint=None) + endpoint = 'https://ws.eastus.quantum.azure.com/' workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace( - workspace_kind='V1', providers=[SimpleNamespace(provider_id='pasqal', target_quotas=None)])) + workspace_kind='V1', endpoint_uri=endpoint, + providers=[SimpleNamespace(provider_id='pasqal', target_quotas=None)])) legacy_row = { 'dimension': 'emulator_hours', 'providerId': 'pasqal', 'scope': 'Subscription', 'limit': 5.0, 'utilization': 1.0, 'holds': 0.0, 'period': 'Monthly' @@ -1100,14 +1098,14 @@ def test_quotas_handler_keeps_v1_behavior_without_v2_usage_calls(self): legacy_client = SimpleNamespace(list=lambda subscription, resource_group, workspace_name: [legacy_row]) from ...operations import workspace as workspace_ops + cli_ctx = object() with patch.object(workspace_ops, 'WorkspaceInfo', return_value=info), \ patch.object(workspace_ops, 'cf_workspaces', return_value=SimpleNamespace(get=lambda rg, ws: workspace)), \ - patch.object(workspace_ops, 'base_url', return_value='https://eastus.quantum.azure.com/'), \ patch.object(workspace_ops, 'cf_quotas', return_value=legacy_client) as client_factory: - rows = workspace_ops.quotas(SimpleNamespace(cli_ctx=object()), 'rg', 'ws') + rows = workspace_ops.quotas(SimpleNamespace(cli_ctx=cli_ctx), 'rg', 'ws') self.assertEqual(rows, [legacy_row]) - client_factory.assert_called_once() + client_factory.assert_called_once_with(cli_ctx, 'sub', 'rg', 'ws', endpoint) class QuantumWorkspaceUserListTest(unittest.TestCase): From f8dcbfb4a1bf9397530e2b9754792d423148a0ee Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Thu, 10 Sep 2026 14:29:33 -0700 Subject: [PATCH 22/24] [Quantum] Address workspace quota review feedback --- .../azext_quantum/operations/workspace.py | 52 ++++++------- .../tests/latest/test_quantum_workspace.py | 74 ++++++++++++++++--- 2 files changed, 87 insertions(+), 39 deletions(-) diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 7ec682b9365..f48615079a2 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -28,6 +28,7 @@ from ..vendored_sdks.azure_mgmt_quantum.models import QuantumWorkspace from ..vendored_sdks.azure_mgmt_quantum.models import ManagedServiceIdentity from ..vendored_sdks.azure_mgmt_quantum.models import Provider, ApiKeys, WorkspaceResourceProperties, KeyType, TargetQuotaAllocations +from ..vendored_sdks.azure_quantum_python._client.models import DimensionScope, MeterPeriod, Priority from .offerings import accept_terms, _get_publisher_and_offer_from_provider_id, _get_terms_from_marketplace, OFFER_NOT_AVAILABLE, PUBLISHER_NOT_AVAILABLE from knack.log import get_logger @@ -278,14 +279,6 @@ def _apply_target_quotas(providers, quota, preserve_existing=False): } -def _target_quota_usage_value(usage, attribute): - if usage is None: - return None - if hasattr(usage, "get"): - return usage.get(_TARGET_QUOTA_USAGE_FIELDS[attribute]) - return getattr(usage, attribute, None) - - def _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage): if not quota: return @@ -325,18 +318,13 @@ def _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage): if item.target_id is not None and item.target_id.lower() == target_id), None ) - if suite_target is None: - raise InvalidArgumentValueError( - f"Cannot validate --quota because target '{target_quota.target_id}' was not found in the " - f"suite offer for provider '{provider.provider_id}'. Run 'az quantum suite-offer quotas " - f"--provider-id {provider.provider_id}' to view available target allocations." - ) - for priority, attribute in _TARGET_QUOTA_PRIORITIES: - if getattr(target_quota, attribute, None) is not None and getattr(suite_target, attribute, None) is None: - raise InvalidArgumentValueError( - f"Cannot validate the {priority} allocation for provider '{provider.provider_id}', target " - f"'{target_quota.target_id}', because the suite offer has no {priority} allocation." - ) + if suite_target is not None: + for priority, attribute in _TARGET_QUOTA_PRIORITIES: + if getattr(target_quota, attribute, None) is not None and getattr(suite_target, attribute, None) is None: + raise InvalidArgumentValueError( + f"Cannot validate the {priority} allocation for provider '{provider.provider_id}', target " + f"'{target_quota.target_id}', because the suite offer has no {priority} allocation." + ) suite_targets[(provider_id, target_id)] = suite_target usage_by_key = {} @@ -364,16 +352,19 @@ def _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage): final_allocation = getattr(target_quota, attribute, None) if final_allocation is None: continue - suite_allocation = getattr(suite_target, attribute) - current_usage = _target_quota_usage_value(usage, attribute) + suite_allocation = getattr(suite_target, attribute, 0) + current_usage = usage.get(_TARGET_QUOTA_USAGE_FIELDS[attribute]) if usage is not None else None current_usage = current_usage if current_usage is not None else 0 if final_allocation < current_usage or final_allocation > suite_allocation: - raise InvalidArgumentValueError( + message = ( f"The final {priority} allocation for provider '{provider.provider_id}', target " f"'{target_quota.target_id}' is {final_allocation} minutes. It must be between the current " f"workspace usage ({current_usage} minutes) and suite allocation ({suite_allocation} minutes), " "inclusive." ) + if suite_target is None: + message += " Set the target quota allocation at the subscription level first." + raise InvalidArgumentValueError(message) def create(cmd, resource_group_name, workspace_name, location, storage_account, skip_role_assignment=False, @@ -392,15 +383,18 @@ def create(cmd, resource_group_name, workspace_name, location, storage_account, if not info.resource_group: raise ResourceNotFoundError("Please run 'az quantum workspace set' first to select a default resource group.") quantum_workspace: QuantumWorkspace = _get_basic_quantum_workspace(location, info, storage_account) + workspace_kind_value = str(_enum_to_value(workspace_kind)).upper() if quota: _require_v2_workspace(workspace_kind) + if workspace_kind_value == 'V2': + skip_autoadd = True # Until the "--skip-role-assignment" parameter is deprecated, use the old non-ARM code to create a workspace without doing a role assignment if skip_role_assignment: _add_quantum_providers(cmd, quantum_workspace, provider_sku_list, auto_accept, skip_autoadd) _apply_target_quotas(quantum_workspace.properties.providers, quota) _validate_target_quota_bounds(cmd, info, quantum_workspace, quota, include_usage=False) - quantum_workspace.properties.api_key_enabled = True + quantum_workspace.properties.api_key_enabled = workspace_kind_value != 'V2' if workspace_kind: quantum_workspace.properties.workspace_kind = workspace_kind poller = client.begin_create_or_update(info.resource_group, info.name, quantum_workspace, polling=False) @@ -585,11 +579,11 @@ def quotas(cmd, resource_group_name, workspace_name): return _merge_workspace_quotas(workspace, usages, legacy_quotas) -_WORKSPACE_QUOTA_SCOPE = "Workspace" -_WORKSPACE_QUOTA_PERIOD = "None" +_WORKSPACE_QUOTA_SCOPE = DimensionScope.WORKSPACE.value +_WORKSPACE_QUOTA_PERIOD = MeterPeriod.NONE.value _TARGET_QUOTA_DIMENSIONS = ( - ("StandardMinutesLifetime", "standard_minutes_lifetime"), - ("HighMinutesLifetime", "high_minutes_lifetime"), + (f"{Priority.STANDARD.value}MinutesLifetime", "standard_minutes_lifetime"), + (f"{Priority.HIGH.value}MinutesLifetime", "high_minutes_lifetime"), ) @@ -645,7 +639,7 @@ def _merge_workspace_quotas(workspace, usages, legacy_quotas=None): display_target_id, dimension, getattr(target_quota, attribute, None), - _target_quota_usage_value(usage_values, attribute), + usage_values.get(_TARGET_QUOTA_USAGE_FIELDS[attribute]) if usage_values is not None else None, )) return rows diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 47589b65242..88594bc8c88 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -22,10 +22,11 @@ from ..._params import QuotaAction from datetime import datetime from ...__init__ import CLI_REPORTED_VERSION -from ...operations.workspace import _apply_target_quotas, _require_v2_workspace, _validate_target_quota_bounds, _validate_storage_account, _autoadd_providers, list_users, update, QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, QUANTUM_WORKSPACE_OWNER_ROLE_ID, SUPPORTED_STORAGE_SKU_TIERS, SUPPORTED_STORAGE_KINDS, DEPLOYMENT_NAME_PREFIX +from ...operations.workspace import _apply_target_quotas, _require_v2_workspace, _validate_target_quota_bounds, _validate_storage_account, _autoadd_providers, create, list_users, update, QUANTUM_WORKSPACE_DATA_CONTRIBUTOR_ROLE_ID, QUANTUM_WORKSPACE_OWNER_ROLE_ID, SUPPORTED_STORAGE_SKU_TIERS, SUPPORTED_STORAGE_KINDS, DEPLOYMENT_NAME_PREFIX from ...operations.workspace import _merge_workspace_quotas from ...commands import transform_workspace_quotas from ...vendored_sdks.azure_mgmt_quantum.models import Provider, TargetQuotaAllocations +from ...vendored_sdks.azure_quantum_python._client.models import Usage from ...vendored_sdks.azure_quantum_python._client.operations._operations import ( build_services_quotas_list_workspace_usages_request, ) @@ -563,6 +564,33 @@ def test_apply_target_quotas_preserves_omitted_values(self): assert provider.target_quotas[0].standard_minutes_lifetime == 0 assert provider.target_quotas[0].high_minutes_lifetime == 50 + def test_skip_role_assignment_api_key_matches_workspace_kind(self): + from ...operations import workspace as workspace_ops + + for workspace_kind, expected_api_key_enabled in ((None, True), ('V1', True), ('V2', False)): + workspace = SimpleNamespace(properties=SimpleNamespace(providers=[])) + poller = SimpleNamespace(done=lambda: True, result=lambda: workspace) + client = SimpleNamespace(begin_create_or_update=lambda *args, **kwargs: poller) + info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') + + with patch.object(workspace_ops, 'cf_workspaces', return_value=client), \ + patch.object(workspace_ops, 'WorkspaceInfo', return_value=info), \ + patch.object(workspace_ops, '_get_basic_quantum_workspace', return_value=workspace), \ + patch.object(workspace_ops, '_add_quantum_providers') as add_providers: + result = create( + SimpleNamespace(cli_ctx=object()), + resource_group_name='group', + workspace_name='workspace', + location='eastus', + storage_account='storage', + skip_role_assignment=True, + workspace_kind=workspace_kind, + ) + + self.assertIs(result, workspace) + self.assertEqual(workspace.properties.api_key_enabled, expected_api_key_enabled) + self.assertEqual(add_providers.call_args.args[-1], workspace_kind == 'V2') + def test_target_quota_validation_errors(self): with self.assertRaises(InvalidArgumentValueError): _require_v2_workspace('V1') @@ -597,7 +625,7 @@ def test_target_quota_bounds_allow_equality_and_match_case_insensitively(self): )) usage = SimpleNamespace( target_id='provider.target', - usage=SimpleNamespace(standard_minutes_lifetime=25, high_minutes_lifetime=10) + usage=Usage({'standardMinutesLifetime': 25, 'highMinutesLifetime': 10}) ) info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') cmd = SimpleNamespace(cli_ctx=object()) @@ -635,7 +663,7 @@ def test_target_quota_bounds_reject_values_outside_inclusive_range(self): )) usage = SimpleNamespace( target_id='provider.target', - usage=SimpleNamespace(standard_minutes_lifetime=usage_value, high_minutes_lifetime=None) + usage=Usage({'standardMinutesLifetime': usage_value}) ) from ...operations import workspace as workspace_ops @@ -662,7 +690,7 @@ def test_target_quota_bounds_validate_high_priority_independently(self): )) usage = SimpleNamespace( target_id='provider.target', - usage=SimpleNamespace(standard_minutes_lifetime=50, high_minutes_lifetime=5) + usage=Usage({'standardMinutesLifetime': 50, 'highMinutesLifetime': 5}) ) info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') cmd = SimpleNamespace(cli_ctx=object()) @@ -688,8 +716,6 @@ def test_target_quota_bounds_require_matching_suite_capacity(self): cases = ( ([], "no suite offer was found for provider 'provider'"), - ([SimpleNamespace(properties=SimpleNamespace(provider_id='provider', target_quotas=[]))], - "target 'provider.target' was not found"), ([SimpleNamespace(properties=SimpleNamespace( provider_id='provider', target_quotas=[TargetQuotaAllocations( target_id='provider.target', standard_minutes_lifetime=100 @@ -708,6 +734,34 @@ def test_target_quota_bounds_require_matching_suite_capacity(self): }], include_usage=True) quota_factory.assert_not_called() + def test_target_quota_bounds_treat_missing_suite_target_as_zero(self): + provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( + target_id='provider.target', standard_minutes_lifetime=50 + )]) + workspace = SimpleNamespace(location='eastus', properties=SimpleNamespace( + endpoint_uri='https://workspace.eastus-v2.quantum.azure.com/', providers=[provider])) + suite_offer = SimpleNamespace(properties=SimpleNamespace(provider_id='provider', target_quotas=[])) + info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') + cmd = SimpleNamespace(cli_ctx=object()) + + from ...operations import workspace as workspace_ops + with patch.object(workspace_ops, 'cf_suite_offers') as suite_factory, \ + patch.object(workspace_ops, 'cf_quotas') as quota_factory: + suite_factory.return_value.list_by_subscription.return_value = [suite_offer] + quota_factory.return_value.list_workspace_usages.return_value = [] + + with self.assertRaisesRegex( + InvalidArgumentValueError, + r'suite allocation \(0 minutes\).*Set the target quota allocation at the subscription level first'): + _validate_target_quota_bounds(cmd, info, workspace, [{ + 'providerId': 'provider', 'targetId': 'provider.target' + }], include_usage=True) + + provider.target_quotas[0].standard_minutes_lifetime = 0 + _validate_target_quota_bounds(cmd, info, workspace, [{ + 'providerId': 'provider', 'targetId': 'provider.target' + }], include_usage=True) + def test_target_quota_bounds_query_usage_once_per_provider(self): provider = Provider(provider_id='provider', target_quotas=[ TargetQuotaAllocations(target_id='provider.target-1', standard_minutes_lifetime=50), @@ -931,7 +985,7 @@ def test_merge_workspace_quotas_with_usage(self): }] usages = [ SimpleNamespace(provider_id='ionq', target_id='ionq.qpu', - usage=SimpleNamespace(standard_minutes_lifetime=5, high_minutes_lifetime=2)), + usage=Usage({'standardMinutesLifetime': 5, 'highMinutesLifetime': 2})), ] rows = _merge_workspace_quotas(workspace, usages, legacy_quotas) @@ -985,7 +1039,7 @@ def test_merge_workspace_quotas_matches_on_provider_and_target(self): usages = [ # Same target id but a different provider -> must not match. SimpleNamespace(provider_id='quantinuum', target_id='shared.target', - usage=SimpleNamespace(standard_minutes_lifetime=9, high_minutes_lifetime=4)), + usage=Usage({'standardMinutesLifetime': 9, 'highMinutesLifetime': 4})), ] rows = _merge_workspace_quotas(workspace, usages) @@ -1000,7 +1054,7 @@ def test_merge_workspace_quotas_includes_usage_without_allocation(self): ])) usages = [ SimpleNamespace(provider_id='IONQ', target_id='ionq.retired-target', - usage=SimpleNamespace(standard_minutes_lifetime=9, high_minutes_lifetime=None)), + usage=Usage({'standardMinutesLifetime': 9})), ] rows = _merge_workspace_quotas(workspace, usages) @@ -1055,7 +1109,7 @@ def test_quotas_handler_queries_v2_usages_without_legacy_quotas(self): usage_by_provider = { 'ionq': [SimpleNamespace(provider_id='ionq', target_id='ionq.qpu', - usage=SimpleNamespace(standard_minutes_lifetime=5, high_minutes_lifetime=2))], + usage=Usage({'standardMinutesLifetime': 5, 'highMinutesLifetime': 2}))], 'pasqal': [], } queried = [] From 2fcca1f1f8c6bd7771fe7abcdf5e378462cf3698 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Thu, 10 Sep 2026 14:30:46 -0700 Subject: [PATCH 23/24] [Quantum] Handle array suite quota usages --- .../azext_quantum/operations/suite_offers.py | 21 ++++-------- .../tests/latest/test_quantum_suite_offers.py | 32 +++++++++++++++++-- .../_client/aio/operations/_operations.py | 6 ++-- .../_client/operations/_operations.py | 6 ++-- 4 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py index b4c40582f90..f993cff221f 100644 --- a/src/quantum/azext_quantum/operations/suite_offers.py +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -15,19 +15,10 @@ # Suite offer quota allocations are always reported at the per-target scope. _SUITE_OFFER_QUOTA_SCOPE = "SubscriptionTarget" -_QUOTA_USAGE_FIELDS = { - "standard_minutes_lifetime": "standardMinutesLifetime", - "high_minutes_lifetime": "highMinutesLifetime", -} - - -def _quota_usage_value(usage, attribute): - if usage is None: - return 0 - if hasattr(usage, "get"): - value = usage.get(_QUOTA_USAGE_FIELDS[attribute]) - else: - value = getattr(usage, attribute, None) + + +def _quota_usage_value(usage, field): + value = usage.get(field) if usage is not None else None return value if value is not None else 0 @@ -142,8 +133,8 @@ def _merge_suite_offer_quotas(offer, usages, provider_id): target_quota.high_minutes_lifetime, ) row["usage"] = _minutes( - _quota_usage_value(usage_values, "standard_minutes_lifetime"), - _quota_usage_value(usage_values, "high_minutes_lifetime"), + _quota_usage_value(usage_values, "standardMinutesLifetime"), + _quota_usage_value(usage_values, "highMinutesLifetime"), ) rows.append(row) diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index b1a9fcea572..7b85a42005d 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -11,7 +11,7 @@ from ...commands import transform_suite_offers, transform_suite_offer_quotas, transform_suite_offer_targets from ..._client_factory import base_url_v2 from ...operations.suite_offers import _merge_suite_offer_quotas -from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsage, ProviderStatus +from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsage, ProviderStatus, Usage from ...vendored_sdks.azure_quantum_python._client._utils.model_base import _deserialize from ...vendored_sdks.azure_quantum_python._client.operations._operations import ( build_services_suite_offers_list_quota_usages_request, @@ -40,7 +40,10 @@ def _offer(provider_id='ionq', location='eastus', quotas=None, target_quotas=Non def _usage(target_id=None, standard=None, high=None, last_modified_time=None): return SimpleNamespace( target_id=target_id, - usage=SimpleNamespace(standard_minutes_lifetime=standard, high_minutes_lifetime=high), + usage=Usage({ + "standardMinutesLifetime": standard, + "highMinutesLifetime": high, + }), last_modified_time=last_modified_time, ) @@ -150,6 +153,31 @@ def test_deserialize_quota_usages(self): self.assertEqual(usages[1].scope, 'SubscriptionTarget') self.assertEqual(usages[1].target_id, 'ionq.qpu') + def test_list_quota_usages_accepts_array_response(self): + data = [{ + 'id': 'usage-1', + 'providerId': 'ionq', + 'scope': 'SubscriptionTarget', + 'targetId': 'ionq.qpu', + 'usage': {'standardMinutesLifetime': 5.0, 'highMinutesLifetime': 1.0}, + 'lastModifiedTime': '2026-01-15T00:00:00Z', + }] + http_response = SimpleNamespace(status_code=200, json=lambda: data) + pipeline_response = SimpleNamespace(http_response=http_response) + fake_client = SimpleNamespace( + _pipeline=SimpleNamespace(run=lambda request, **kwargs: pipeline_response), + format_url=lambda url, **kwargs: url, + ) + fake_config = SimpleNamespace(api_version='2026-01-15-preview', endpoint='https://example') + fake_serialize = SimpleNamespace(url=lambda name, value, kind, **kwargs: value) + operations = ServicesSuiteOffersOperations(fake_client, fake_config, fake_serialize, object()) + + usages = list(operations.list_quota_usages('sub', 'ionq')) + + self.assertEqual(len(usages), 1) + self.assertEqual(usages[0].target_id, 'ionq.qpu') + self.assertEqual(usages[0].usage['standardMinutesLifetime'], 5.0) + def test_build_suite_offers_get_provider_status_request(self): request = build_services_suite_offers_get_provider_status_request( subscription_id='00000000-0000-0000-0000-000000000000', diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py index 6f478e5b341..493adab1bca 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/aio/operations/_operations.py @@ -2270,13 +2270,15 @@ def prepare_request(next_link=None): async def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() + values = deserialized if isinstance(deserialized, list) else deserialized.get("value", []) list_of_elem = _deserialize( list[_models.QuotaUsage], - deserialized.get("value", []), + values, ) if cls: list_of_elem = cls(list_of_elem) # type: ignore - return deserialized.get("nextLink") or None, AsyncList(list_of_elem) + next_link = None if isinstance(deserialized, list) else deserialized.get("nextLink") or None + return next_link, AsyncList(list_of_elem) async def get_next(next_link=None): _request = prepare_request(next_link) diff --git a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py index e9eed057a58..7b9bf3ac0a2 100644 --- a/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py +++ b/src/quantum/azext_quantum/vendored_sdks/azure_quantum_python/_client/operations/_operations.py @@ -2835,13 +2835,15 @@ def prepare_request(next_link=None): def extract_data(pipeline_response): deserialized = pipeline_response.http_response.json() + values = deserialized if isinstance(deserialized, list) else deserialized.get("value", []) list_of_elem = _deserialize( list[_models.QuotaUsage], - deserialized.get("value", []), + values, ) if cls: list_of_elem = cls(list_of_elem) # type: ignore - return deserialized.get("nextLink") or None, iter(list_of_elem) + next_link = None if isinstance(deserialized, list) else deserialized.get("nextLink") or None + return next_link, iter(list_of_elem) def get_next(next_link=None): _request = prepare_request(next_link) From 2de9a905911e9b364375bf16e9a0e72a7ca9a157 Mon Sep 17 00:00:00 2001 From: "Ekaterina Legacheva (AKVELON INC)" Date: Thu, 10 Sep 2026 21:17:28 -0700 Subject: [PATCH 24/24] [Quantum] Address latest suite offer review --- src/quantum/HISTORY.rst | 6 +- src/quantum/azext_quantum/_client_factory.py | 6 +- src/quantum/azext_quantum/_help.py | 26 ++++----- src/quantum/azext_quantum/_params.py | 2 +- src/quantum/azext_quantum/commands.py | 6 +- .../azext_quantum/operations/suite_offers.py | 30 +++++----- .../azext_quantum/operations/workspace.py | 56 ++++++++++++------- .../tests/latest/test_quantum_suite_offers.py | 27 +++++++++ .../tests/latest/test_quantum_workspace.py | 31 +++++----- src/quantum/setup.py | 1 - 10 files changed, 116 insertions(+), 75 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index f3e436e3051..a16bcbc922c 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -5,15 +5,15 @@ Release History 1.0.0b27 ++++++++++++++ -* Added the ``az quantum suite-offer target list`` command to list the targets, availability, and overall, Standard, and High average queue times available through a suite offer provider account, without requiring a workspace. +* Added the ``az quantum suite-offer target list`` command to list the targets, availability, and overall, Standard, and High average queue times available through a suite offer, without requiring a workspace. * Removed the redundant provider column from the ``az quantum suite-offer target list`` table output. * Updated ``az quantum suite-offer quotas`` to return ``0`` for missing Standard and High usage values. * Updated the ``az quantum workspace quotas`` command to include v2 target quota allocations and usages while preserving the existing response format for v1 providers. -* Added always-on validation for V2 workspace target quota allocations on create and update, allowing final Standard and High values between current workspace usage and suite target allocation, inclusive. +* Added always-on validation for V2 workspace target quota allocations on create and update, allowing requested Standard and High values between current workspace usage and suite target allocation, inclusive. 1.0.0b26 ++++++++++++++ -* Added the ``az quantum suite-offer quotas`` command to view quota allocations merged with their consumed usages for a suite offer provider account in the subscription. +* Added the ``az quantum suite-offer quotas`` command to view quota allocations merged with their consumed usages for a suite offer in the subscription. 1.0.0b25 ++++++++++++++ diff --git a/src/quantum/azext_quantum/_client_factory.py b/src/quantum/azext_quantum/_client_factory.py index 8f8e596d2b3..fe1878af61c 100644 --- a/src/quantum/azext_quantum/_client_factory.py +++ b/src/quantum/azext_quantum/_client_factory.py @@ -31,8 +31,6 @@ def base_url(location): def base_url_v2(location): if 'AZURE_QUANTUM_BASEURL_V2' in os.environ: return os.environ['AZURE_QUANTUM_BASEURL_V2'] - if is_env('canary'): - return "https://eastus2euap-v2.quantum.azure.com/" normalized_location = normalize_location(location) if is_env('dogfood'): return f"https://{normalized_location}-v2.quantum-test.azure.com/" @@ -96,8 +94,8 @@ 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_suite_offers_data_plane(cli_ctx, subscription: str, endpoint: str): - # resource_group and workspace name are unused: the data-plane endpoint is supplied directly. +def cf_suite_offers_data_plane(cli_ctx, subscription: str, location: str): + endpoint = base_url_v2(location) return cf_quantum(cli_ctx, subscription, None, None, endpoint).services.suite_offers diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index ed3fb851ce8..877c926a3da 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -235,18 +235,18 @@ helps['quantum suite-offer quotas'] = """ type: command - short-summary: View quota allocations and their consumed usages for a suite offer provider account in the current subscription. + short-summary: View quota allocations and their consumed usages for a suite offer in the current subscription. long-summary: | - Returns the v2 quota allocations (limits) for each target of the provider account together + Returns the v2 quota allocations (limits) for each target of the suite offer together with the consumed usages. Each entry reports the allocated and used standard and high priority - minutes over the lifetime of the provider account. Missing usage values are returned as 0. + minutes. Missing usage values are returned as 0. examples: - - name: View the quota usages for a suite offer provider account. + - name: View the quota usages for a suite offer. text: |- - az quantum suite-offer quotas --provider-id MyProviderAccount -o table - - name: View the raw quota usage details for a suite offer provider account. + az quantum suite-offer quotas --provider-id MyProvider -o table + - name: View the raw quota usage details for a suite offer. text: |- - az quantum suite-offer quotas -p MyProviderAccount + az quantum suite-offer quotas -p MyProvider """ helps['quantum suite-offer target'] = """ @@ -258,17 +258,17 @@ type: command short-summary: List the targets and their status available through a suite offer, without requiring a workspace. long-summary: | - Returns each target exposed by the suite offer provider account together with its current + Returns each target exposed by the suite offer together with its current availability and overall average queue time. Standard- and High-priority average queue times are also returned when supplied by the provider. Data is resolved directly from the data plane without requiring an Azure Quantum workspace. examples: - name: List the targets available in a suite offer. text: |- - az quantum suite-offer target list --provider-id MyProviderAccount -o table - - name: List the raw target status details for a suite offer provider account. + az quantum suite-offer target list --provider-id MyProvider -o table + - name: List the raw target status details for a suite offer. text: |- - az quantum suite-offer target list -p MyProviderAccount + az quantum suite-offer target list -p MyProvider """ helps['quantum offerings'] = """ @@ -365,7 +365,7 @@ type: command short-summary: Create a new Azure Quantum workspace. long-summary: >- - Target quota values are absolute. For V2 workspaces, each final Standard and High allocation is validated + Target quota values are absolute. For V2 workspaces, each requested Standard and High allocation is validated against the provider's suite target allocation before the workspace is created. examples: - name: Create a new Azure Quantum workspace with the providers that offer free credit. @@ -446,7 +446,7 @@ type: command short-summary: Update the given (or current) Azure Quantum workspace. long-summary: >- - Target quota values are absolute. Each final Standard and High allocation is validated against the current + Target quota values are absolute. Each requested Standard and High allocation is validated against the current workspace target usage and provider's suite target allocation, with equality allowed at both boundaries. Priority values omitted from an existing target allocation are preserved and validated. examples: diff --git a/src/quantum/azext_quantum/_params.py b/src/quantum/azext_quantum/_params.py index 9103a4afdf0..1e6d0cc72af 100644 --- a/src/quantum/azext_quantum/_params.py +++ b/src/quantum/azext_quantum/_params.py @@ -184,7 +184,7 @@ def load_arguments(self, _): # pylint: disable=too-many-locals entry_point_type = CLIArgumentType(help='The entry point for the QIR program or circuit. Required for some provider QIR jobs.') skip_autoadd_type = CLIArgumentType(help='If specified, the plans that offer free credits will not automatically be added.') workspace_kind_type = CLIArgumentType(options_list=['--workspace-kind'], help='The kind of the workspace to create.', choices=['V1', 'V2']) - quota_type = CLIArgumentType(options_list=['--quota'], help='Final target quota allocation for a V2 workspace as provider-id, target-id, standard-minutes-lifetime, and optional high-minutes-lifetime key=value pairs, a JSON object or array, or `@{file}` with JSON content. Use --workspace-kind V2 when creating a workspace. Values are absolute and cannot exceed the suite target allocation or, when updating, be below current workspace usage. standard-minutes-lifetime is required for a new allocation. camelCase keys (providerId, targetId, ...) are also accepted. Repeat --quota once per target.', action=QuotaAction, nargs='+') + quota_type = CLIArgumentType(options_list=['--quota'], help='Target quota allocation for a V2 workspace as provider-id, target-id, standard-minutes-lifetime, and optional high-minutes-lifetime key=value pairs, a JSON object or array, or `@{file}` with JSON content. Use --workspace-kind V2 when creating a workspace. Values are absolute and cannot exceed the suite target allocation or, when updating, be below current workspace usage. standard-minutes-lifetime is required for a new allocation. camelCase keys (providerId, targetId, ...) are also accepted. Repeat --quota once per target.', action=QuotaAction, nargs='+') key_type = CLIArgumentType(options_list=['--key-type'], help='The api keys to be regenerated, should be Primary and/or Secondary.') enable_key_type = CLIArgumentType(options_list=['--enable-api-key'], help='Enable or disable API key authentication.') job_type_type = CLIArgumentType(options_list=['--job-type'], help='Job type to be listed, example "QuantumComputing".') diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index 2a26f7133ba..ed79a7ed2ea 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -118,7 +118,7 @@ def one(offer): def _quota_hours(minutes): - """Convert lifetime quota minutes to hours (2 dp), matching the Quantum studio UI.""" + """Convert lifetime quota minutes to hours (2 dp).""" hours = 0 if minutes is None else minutes / 60 return f"{hours:.2f}" @@ -153,10 +153,10 @@ def value(key): return result return OrderedDict([ - ('Dimension', quota.get('dimension', '')), - ('Provider ID', quota.get('providerId', '')), ('Scope', quota.get('scope', '')), + ('Provider ID', quota.get('providerId', '')), ('Target', quota.get('targetId', '')), + ('Dimension', quota.get('dimension', '')), ('Limit', value('limit')), ('Utilization', value('utilization')), ('Holds', value('holds')), diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py index f993cff221f..aea814f8d89 100644 --- a/src/quantum/azext_quantum/operations/suite_offers.py +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -11,9 +11,9 @@ from azure.cli.core.commands.client_factory import get_subscription_id from azure.core.exceptions import ResourceNotFoundError as AzureResourceNotFoundError -from .._client_factory import cf_suite_offers, cf_suite_offers_data_plane, base_url_v2 +from .._client_factory import cf_suite_offers, cf_suite_offers_data_plane -# Suite offer quota allocations are always reported at the per-target scope. +# Scope used for target quota rows returned by this command. _SUITE_OFFER_QUOTA_SCOPE = "SubscriptionTarget" @@ -33,11 +33,11 @@ def list_suite_offers(cmd): def suite_offer_quotas(cmd, provider_id): """ Return the v2 quota allocations, merged with their consumed usages, for a suite offer - provider account in the current subscription. + in the current subscription. """ subscription_id = get_subscription_id(cmd.cli_ctx) - # 1. Control-plane: locate the suite offer for the requested provider account. + # 1. Control-plane: locate the suite offer for the requested provider. offers = cf_suite_offers(cmd.cli_ctx).list_by_subscription() offer = next( (o for o in offers @@ -48,17 +48,16 @@ def suite_offer_quotas(cmd, provider_id): ) if offer is None: raise InvalidArgumentValueError( - f"No suite offer was found for provider account '{provider_id}' in subscription '{subscription_id}'." + f"No suite offer was found for provider '{provider_id}' in subscription '{subscription_id}'." ) - # 2. Data-plane (v2): fetch the consumed quota usages for that provider account. - endpoint = base_url_v2(offer.properties.location) - client = cf_suite_offers_data_plane(cmd.cli_ctx, subscription_id, endpoint) + # 2. Data-plane (v2): fetch the consumed quota usages for that provider. + client = cf_suite_offers_data_plane(cmd.cli_ctx, subscription_id, offer.properties.location) try: usages = client.list_quota_usages(subscription_id, provider_id) except AzureResourceNotFoundError as ex: raise ResourceNotFoundError( - f"No quota usages were found for provider account '{provider_id}'." + f"No quota usages were found for provider '{provider_id}'." ) from ex # 3. Merge allocations (limits) with usages (consumed). @@ -67,7 +66,7 @@ def suite_offer_quotas(cmd, provider_id): def suite_offer_targets(cmd, provider_id): """ - List the targets and their status available through a suite offer provider account, + List the targets and their status available through a suite offer, without requiring an Azure Quantum workspace. """ subscription_id = get_subscription_id(cmd.cli_ctx) @@ -83,17 +82,16 @@ def suite_offer_targets(cmd, provider_id): ) if offer is None: raise InvalidArgumentValueError( - f"No suite offer was found for provider account '{provider_id}' in subscription '{subscription_id}'." + f"No suite offer was found for provider '{provider_id}' in subscription '{subscription_id}'." ) - # 2. Data-plane (v2): fetch the provider/target status for that provider account. - endpoint = base_url_v2(offer.properties.location) - client = cf_suite_offers_data_plane(cmd.cli_ctx, subscription_id, endpoint) + # 2. Data-plane (v2): fetch the provider and target status. + client = cf_suite_offers_data_plane(cmd.cli_ctx, subscription_id, offer.properties.location) try: status = client.get_provider_status(subscription_id, provider_id) except AzureResourceNotFoundError as ex: raise ResourceNotFoundError( - f"No target status was found for provider account '{provider_id}'." + f"No target status was found for provider '{provider_id}'." ) from ex # The endpoint returns a single provider; wrap it so the table transformer shared with @@ -120,7 +118,7 @@ def _merge_suite_offer_quotas(offer, usages, provider_id): } rows = [] - for target_quota in sorted(offer.properties.target_quotas or [], key=lambda q: q.target_id or ""): + for target_quota in offer.properties.target_quotas or []: usage = usage_by_target.get(target_quota.target_id) usage_values = usage.usage if usage is not None else None diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index f48615079a2..1d4af7f0a3f 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -28,7 +28,7 @@ from ..vendored_sdks.azure_mgmt_quantum.models import QuantumWorkspace from ..vendored_sdks.azure_mgmt_quantum.models import ManagedServiceIdentity from ..vendored_sdks.azure_mgmt_quantum.models import Provider, ApiKeys, WorkspaceResourceProperties, KeyType, TargetQuotaAllocations -from ..vendored_sdks.azure_quantum_python._client.models import DimensionScope, MeterPeriod, Priority +from ..vendored_sdks.azure_quantum_python._client.models import DimensionScope, MeterPeriod from .offerings import accept_terms, _get_publisher_and_offer_from_provider_id, _get_terms_from_marketplace, OFFER_NOT_AVAILABLE, PUBLISHER_NOT_AVAILABLE from knack.log import get_logger @@ -318,13 +318,23 @@ def _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage): if item.target_id is not None and item.target_id.lower() == target_id), None ) - if suite_target is not None: - for priority, attribute in _TARGET_QUOTA_PRIORITIES: - if getattr(target_quota, attribute, None) is not None and getattr(suite_target, attribute, None) is None: - raise InvalidArgumentValueError( - f"Cannot validate the {priority} allocation for provider '{provider.provider_id}', target " - f"'{target_quota.target_id}', because the suite offer has no {priority} allocation." - ) + for priority, attribute in _TARGET_QUOTA_PRIORITIES: + final_allocation = getattr(target_quota, attribute, None) + if final_allocation is None: + continue + if suite_target is None: + raise InvalidArgumentValueError( + f"--quota requests {final_allocation} minutes of {priority} time for provider " + f"'{provider.provider_id}', target '{target_quota.target_id}', but the subscription has no " + "allocation for that target.\n" + "Allocate it at the subscription level first, then retry. To see current allocations run:\n" + f"\taz quantum suite-offer quotas --provider-id {provider.provider_id}" + ) + if getattr(suite_target, attribute, None) is None: + raise InvalidArgumentValueError( + f"Cannot validate the {priority} allocation for provider '{provider.provider_id}', target " + f"'{target_quota.target_id}', because the suite offer has no {priority} allocation." + ) suite_targets[(provider_id, target_id)] = suite_target usage_by_key = {} @@ -352,19 +362,25 @@ def _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage): final_allocation = getattr(target_quota, attribute, None) if final_allocation is None: continue - suite_allocation = getattr(suite_target, attribute, 0) + suite_allocation = getattr(suite_target, attribute) current_usage = usage.get(_TARGET_QUOTA_USAGE_FIELDS[attribute]) if usage is not None else None current_usage = current_usage if current_usage is not None else 0 - if final_allocation < current_usage or final_allocation > suite_allocation: - message = ( - f"The final {priority} allocation for provider '{provider.provider_id}', target " - f"'{target_quota.target_id}' is {final_allocation} minutes. It must be between the current " - f"workspace usage ({current_usage} minutes) and suite allocation ({suite_allocation} minutes), " - "inclusive." + if final_allocation < current_usage: + raise InvalidArgumentValueError( + f"--quota would set the {priority} allocation for provider '{provider.provider_id}', target " + f"'{target_quota.target_id}' to {final_allocation} minutes, below the {current_usage} minutes " + f"the workspace has already used. Specify at least {current_usage}.\n" + "To see current usage run:\n" + f"\taz quantum workspace quotas -g {info.resource_group} -w {info.name}" + ) + if final_allocation > suite_allocation: + raise InvalidArgumentValueError( + f"--quota would set the {priority} allocation for provider '{provider.provider_id}', target " + f"'{target_quota.target_id}' to {final_allocation} minutes, above the {suite_allocation} minutes " + f"allocated to the subscription. Specify at most {suite_allocation}.\n" + "To see subscription allocations run:\n" + f"\taz quantum suite-offer quotas --provider-id {provider.provider_id}" ) - if suite_target is None: - message += " Set the target quota allocation at the subscription level first." - raise InvalidArgumentValueError(message) def create(cmd, resource_group_name, workspace_name, location, storage_account, skip_role_assignment=False, @@ -582,8 +598,8 @@ def quotas(cmd, resource_group_name, workspace_name): _WORKSPACE_QUOTA_SCOPE = DimensionScope.WORKSPACE.value _WORKSPACE_QUOTA_PERIOD = MeterPeriod.NONE.value _TARGET_QUOTA_DIMENSIONS = ( - (f"{Priority.STANDARD.value}MinutesLifetime", "standard_minutes_lifetime"), - (f"{Priority.HIGH.value}MinutesLifetime", "high_minutes_lifetime"), + ("StandardMinutesLifetime", "standard_minutes_lifetime"), + ("HighMinutesLifetime", "high_minutes_lifetime"), ) diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index 7b85a42005d..28d9b2b92b3 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py @@ -4,11 +4,13 @@ # -------------------------------------------------------------------------------------------- from types import SimpleNamespace +from unittest.mock import patch from azure.cli.testsdk.scenario_tests import live_only from azure.cli.testsdk import ScenarioTest from ...commands import transform_suite_offers, transform_suite_offer_quotas, transform_suite_offer_targets +from ... import _client_factory from ..._client_factory import base_url_v2 from ...operations.suite_offers import _merge_suite_offer_quotas from ...vendored_sdks.azure_quantum_python._client.models import QuotaUsage, ProviderStatus, Usage @@ -114,6 +116,21 @@ def test_transform_suite_offer_quotas(self): def test_base_url_v2(self): self.assertEqual(base_url_v2('East US'), 'https://eastus-v2.quantum.azure.com/') + with patch.dict('os.environ', {'AZURE_QUANTUM_ENV': 'canary'}, clear=True): + self.assertEqual(base_url_v2('West US'), 'https://westus-v2.quantum.azure.com/') + + def test_suite_offers_data_plane_factory_builds_endpoint_from_location(self): + suite_offers = object() + client = SimpleNamespace(services=SimpleNamespace(suite_offers=suite_offers)) + cli_ctx = object() + + with patch.object(_client_factory, 'cf_quantum', return_value=client) as quantum_factory: + result = _client_factory.cf_suite_offers_data_plane(cli_ctx, 'sub', 'East US') + + self.assertIs(result, suite_offers) + quantum_factory.assert_called_once_with( + cli_ctx, 'sub', None, None, 'https://eastus-v2.quantum.azure.com/') + def test_build_suite_offers_list_quota_usages_request(self): request = build_services_suite_offers_list_quota_usages_request( subscription_id='00000000-0000-0000-0000-000000000000', @@ -318,6 +335,16 @@ def test_merge_quotas_target_with_usage(self): self.assertEqual(row['allocation'], {'standardMinutesLifetime': 30, 'highMinutesLifetime': 15}) self.assertEqual(row['usage'], {'standardMinutesLifetime': 5, 'highMinutesLifetime': 2}) + def test_merge_quotas_preserves_backend_allocation_order(self): + offer = _offer(target_quotas=[ + _allocation(standard=30, high=15, target_id='ionq.z-target'), + _allocation(standard=20, high=10, target_id='ionq.a-target'), + ]) + + rows = _merge_suite_offer_quotas(offer, [], 'ionq') + + self.assertEqual([row['targetId'] for row in rows], ['ionq.z-target', 'ionq.a-target']) + def test_merge_quotas_target_without_usage(self): offer = _offer( target_quotas=[_allocation(standard=30, high=None, target_id='ionq.qpu')], diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 88594bc8c88..1639369e169 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py @@ -647,10 +647,13 @@ def test_target_quota_bounds_reject_values_outside_inclusive_range(self): info = SimpleNamespace(subscription='sub', resource_group='group', name='workspace') cmd = SimpleNamespace(cli_ctx=object()) - for final_value, usage_value, expected_text in ( - (24, 25, 'current workspace usage (25 minutes)'), - (25, 25.5, 'current workspace usage (25.5 minutes)'), - (101, 25, 'suite allocation (100 minutes)')): + for final_value, usage_value, expected_text, expected_command in ( + (24, 25, 'below the 25 minutes the workspace has already used. Specify at least 25.', + 'az quantum workspace quotas -g group -w workspace'), + (25, 25.5, 'below the 25.5 minutes the workspace has already used. Specify at least 25.5.', + 'az quantum workspace quotas -g group -w workspace'), + (101, 25, 'above the 100 minutes allocated to the subscription. Specify at most 100.', + 'az quantum suite-offer quotas --provider-id provider')): provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( target_id='provider.target', standard_minutes_lifetime=final_value )]) @@ -672,10 +675,12 @@ def test_target_quota_bounds_reject_values_outside_inclusive_range(self): suite_factory.return_value.list_by_subscription.return_value = [suite_offer] quota_factory.return_value.list_workspace_usages.return_value = [usage] - with self.assertRaisesRegex(InvalidArgumentValueError, re.escape(expected_text)): + with self.assertRaises(InvalidArgumentValueError) as error: _validate_target_quota_bounds(cmd, info, workspace, [{ 'providerId': 'provider', 'targetId': 'provider.target' }], include_usage=True) + self.assertIn(expected_text, str(error.exception)) + self.assertIn(expected_command, str(error.exception)) def test_target_quota_bounds_validate_high_priority_independently(self): provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( @@ -701,7 +706,7 @@ def test_target_quota_bounds_validate_high_priority_independently(self): suite_factory.return_value.list_by_subscription.return_value = [suite_offer] quota_factory.return_value.list_workspace_usages.return_value = [usage] - with self.assertRaisesRegex(InvalidArgumentValueError, 'final High allocation'): + with self.assertRaisesRegex(InvalidArgumentValueError, 'High allocation.*above the 20 minutes'): _validate_target_quota_bounds(cmd, info, workspace, [{ 'providerId': 'provider', 'targetId': 'provider.target' }], include_usage=True) @@ -734,7 +739,7 @@ def test_target_quota_bounds_require_matching_suite_capacity(self): }], include_usage=True) quota_factory.assert_not_called() - def test_target_quota_bounds_treat_missing_suite_target_as_zero(self): + def test_target_quota_bounds_reject_missing_suite_target(self): provider = Provider(provider_id='provider', target_quotas=[TargetQuotaAllocations( target_id='provider.target', standard_minutes_lifetime=50 )]) @@ -752,15 +757,13 @@ def test_target_quota_bounds_treat_missing_suite_target_as_zero(self): with self.assertRaisesRegex( InvalidArgumentValueError, - r'suite allocation \(0 minutes\).*Set the target quota allocation at the subscription level first'): + r"(?s)subscription has no allocation for that target.*" + r"Allocate it at the subscription level first.*" + r"az quantum suite-offer quotas --provider-id provider"): _validate_target_quota_bounds(cmd, info, workspace, [{ 'providerId': 'provider', 'targetId': 'provider.target' }], include_usage=True) - - provider.target_quotas[0].standard_minutes_lifetime = 0 - _validate_target_quota_bounds(cmd, info, workspace, [{ - 'providerId': 'provider', 'targetId': 'provider.target' - }], include_usage=True) + quota_factory.assert_not_called() def test_target_quota_bounds_query_usage_once_per_provider(self): provider = Provider(provider_id='provider', target_quotas=[ @@ -1087,7 +1090,7 @@ def test_transform_workspace_quotas_preserves_mixed_dimensions(self): table = transform_workspace_quotas(quotas) self.assertEqual(list(table[0].keys()), [ - 'Dimension', 'Provider ID', 'Scope', 'Target', 'Limit', 'Utilization', 'Holds', 'Period' + 'Scope', 'Provider ID', 'Target', 'Dimension', 'Limit', 'Utilization', 'Holds', 'Period' ]) self.assertEqual(table[0]['Target'], '') self.assertEqual(table[0]['Limit'], 5.0) diff --git a/src/quantum/setup.py b/src/quantum/setup.py index 0ce47c0a85d..e7be70b7fba 100644 --- a/src/quantum/setup.py +++ b/src/quantum/setup.py @@ -51,6 +51,5 @@ packages=find_packages(), package_data={ 'azext_quantum': ['azext_metadata.json', 'operations/templates/create-workspace-and-assign-role.json'], - 'azext_quantum.vendored_sdks.azure_quantum_python._client': ['py.typed'], }, )