diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index 7e109e3d663..a16bcbc922c 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -3,6 +3,18 @@ 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, 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 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 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..fe1878af61c 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,11 @@ 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, location: str): + endpoint = base_url_v2(location) + 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..877c926a3da 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: |- @@ -233,6 +233,44 @@ 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 in the current subscription. + long-summary: | + 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. Missing usage values are returned as 0. + examples: + - name: View the quota usages for a suite offer. + text: |- + 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 MyProvider +""" + +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 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 MyProvider -o table + - name: List the raw target status details for a suite offer. + text: |- + az quantum suite-offer target list -p MyProvider +""" + helps['quantum offerings'] = """ type: group short-summary: Manage provider offerings for Azure Quantum. @@ -326,6 +364,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 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. text: |- @@ -369,11 +410,15 @@ helps['quantum workspace quotas'] = """ type: command - short-summary: List the quotas for the given (or current) Azure Quantum workspace. + short-summary: List quota allocations and consumed usages for an Azure Quantum workspace. + long-summary: | + 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: 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 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 + az quantum workspace quotas -g MyResourceGroup -w MyWorkspace -o table """ helps['quantum workspace set'] = """ @@ -400,6 +445,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 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: - 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 3e2a90f5b5f..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='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='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".') @@ -316,3 +316,9 @@ 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) + + 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 d74eb0a00a8..ed79a7ed2ea 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -30,6 +30,20 @@ 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']), + ('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'] + ] + + def transform_job(result): transformed_result = OrderedDict([ ('Name', result['name']), @@ -103,6 +117,55 @@ def one(offer): return [one(offer) for offer in suite_offers] +def _quota_hours(minutes): + """Convert lifetime quota minutes to hours (2 dp).""" + hours = 0 if minutes is None else minutes / 60 + return f"{hours:.2f}" + + +def transform_suite_offer_quotas(quotas): + def one(quota): + allocation = quota.get('allocation') or {} + usage = quota.get('usage') or {} + + def cell(source, key): + return _quota_hours(source.get(key)) + + return OrderedDict([ + ('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')) + ]) + + return [one(quota) for quota in quotas] + + +def transform_workspace_quotas(quotas): + def one(quota): + is_target_quota = quota.get('targetId') is not None + + def value(key): + result = quota.get(key, 0) + if is_target_quota and isinstance(result, float): + return round(result, 2) + return result + + return OrderedDict([ + ('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')), + ('Period', quota.get('period', '')), + ]) + + return [one(quota) for quota in quotas] + + def transform_output(results): def one(key, value): repeat = round(20 * value) @@ -167,7 +230,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') @@ -206,3 +269,7 @@ 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_suite_offer_targets) diff --git a/src/quantum/azext_quantum/operations/suite_offers.py b/src/quantum/azext_quantum/operations/suite_offers.py index ee33bf4a8af..aea814f8d89 100644 --- a/src/quantum/azext_quantum/operations/suite_offers.py +++ b/src/quantum/azext_quantum/operations/suite_offers.py @@ -5,7 +5,21 @@ # 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_offers_data_plane + +# Scope used for target quota rows returned by this command. +_SUITE_OFFER_QUOTA_SCOPE = "SubscriptionTarget" + + +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 def list_suite_offers(cmd): @@ -14,3 +28,112 @@ 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 + in the current subscription. + """ + subscription_id = get_subscription_id(cmd.cli_ctx) + + # 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 + 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 '{provider_id}' in subscription '{subscription_id}'." + ) + + # 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 '{provider_id}'." + ) from ex + + # 3. Merge allocations (limits) with usages (consumed). + 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, + 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 '{provider_id}' in subscription '{subscription_id}'." + ) + + # 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 '{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.""" + return OrderedDict([ + ("standardMinutesLifetime", standard), + ("highMinutesLifetime", high), + ]) + + +def _merge_suite_offer_quotas(offer, usages, provider_id): + """ + Build one row per target quota allocation, attaching its matching data-plane usage. + Suite offer quotas are reported at the SubscriptionTarget scope only. + """ + # 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 = [] + 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 + + row = OrderedDict() + row["providerId"] = provider_id + row["scope"] = _SUITE_OFFER_QUOTA_SCOPE + row["targetId"] = target_quota.target_id + row["allocation"] = _minutes( + target_quota.standard_minutes_lifetime, + target_quota.high_minutes_lifetime, + ) + row["usage"] = _minutes( + _quota_usage_value(usage_values, "standardMinutesLifetime"), + _quota_usage_value(usage_values, "highMinutesLifetime"), + ) + rows.append(row) + + return rows diff --git a/src/quantum/azext_quantum/operations/workspace.py b/src/quantum/azext_quantum/operations/workspace.py index 105433c9ff1..1d4af7f0a3f 100644 --- a/src/quantum/azext_quantum/operations/workspace.py +++ b/src/quantum/azext_quantum/operations/workspace.py @@ -21,12 +21,14 @@ 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 .._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 from ..vendored_sdks.azure_mgmt_quantum.models import Provider, ApiKeys, WorkspaceResourceProperties, KeyType, TargetQuotaAllocations +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 @@ -267,6 +269,120 @@ 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"), +) +_TARGET_QUOTA_USAGE_FIELDS = { + "standard_minutes_lifetime": "standardMinutesLifetime", + "high_minutes_lifetime": "highMinutesLifetime", +} + + +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 + ) + 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 = {} + if include_usage: + usage_client = cf_quotas( + 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: + 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 []: + 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 = 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: + 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}" + ) + + 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): """ @@ -283,14 +399,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) - quantum_workspace.properties.api_key_enabled = True + _validate_target_quota_bounds(cmd, info, quantum_workspace, quota, include_usage=False) + 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) @@ -307,6 +427,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} @@ -444,12 +565,100 @@ 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 quota allocations and usages for the given (or current) Azure Quantum workspace. """ 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) + + 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 + 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, endpoint) + for provider in providers or []: + try: + 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 []) + 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) + + +_WORKSPACE_QUOTA_SCOPE = DimensionScope.WORKSPACE.value +_WORKSPACE_QUOTA_PERIOD = MeterPeriod.NONE.value +_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, legacy_quotas=None): + """ + Preserve legacy quota rows and append one flat row per target and priority for v2 quotas. + """ + usage_by_key = { + ((usage.provider_id or '').lower(), usage.target_id.lower()): 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 = [row for row in (legacy_quotas or [])] + for provider in sorted(providers or [], key=lambda p: p.provider_id or ""): + 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 + 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), + usage_values.get(_TARGET_QUOTA_USAGE_FIELDS[attribute]) if usage_values is not None else None, + )) + + return rows def set(cmd, workspace_name, resource_group_name): @@ -534,6 +743,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_suite_offers.py b/src/quantum/azext_quantum/tests/latest/test_quantum_suite_offers.py index b7669d5c6e3..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 @@ -3,10 +3,51 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +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 +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 +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_get_provider_status_request, + ServicesSuiteOffersOperations, +) + + +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=Usage({ + "standardMinutesLifetime": standard, + "highMinutesLifetime": high, + }), + last_modified_time=last_modified_time, + ) class QuantumSuiteOffersScenarioTest(ScenarioTest): @@ -36,7 +77,348 @@ 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': 'SubscriptionTarget', + '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), 2) + row = table[0] + self.assertEqual(list(row.keys()), [ + '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') + missing_row = table[1] + 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/') + + 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', + 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(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['standardMinutesLifetime'], 40.0) + 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', + 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_single_object(self): + data = { + 'id': 'ionq', + 'currentAvailability': 'Available', + 'targets': [ + { + 'id': 'ionq.qpu', + 'currentAvailability': 'Available', + 'averageQueueTime': 42, + 'averageQueueTimeHighPriority': 10, + 'averageQueueTimeStandardPriority': 60, + }, + {'id': 'ionq.simulator', 'currentAvailability': 'Available', 'averageQueueTime': 0}, + ], + } + + provider = _deserialize(ProviderStatus, data) + + self.assertEqual(provider.id, 'ionq') + self.assertEqual(provider.current_availability, 'Available') + 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. + single = { + 'id': 'ionq', + 'currentAvailability': 'Available', + 'targets': [ + { + 'id': 'ionq.qpu', + 'currentAvailability': 'Available', + 'averageQueueTime': 7, + 'averageQueueTimeHighPriority': 2, + 'averageQueueTimeStandardPriority': 9, + }, + ], + } + 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.get_provider_status( + '00000000-0000-0000-0000-000000000000', 'ionq' + ) + + 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) + 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 = [ + { + 'id': 'ionq', + 'currentAvailability': 'Available', + 'targets': [ + { + '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), 2) + row = table[0] + self.assertEqual(list(row.keys()), [ + '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( + 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), # subscription-scope usage ignored + _deserialize(QuotaUsage, { + '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') + + 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_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')], + ) + + rows = _merge_suite_offer_quotas(offer, [], 'ionq') + + self.assertEqual(len(rows), 1) + row = rows[0] + self.assertEqual(row['allocation'], {'standardMinutesLifetime': 30, '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( + 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]['targetId'], 'ionq.qpu') + self.assertEqual(rows[0]['usage'], {'standardMinutesLifetime': 0, 'highMinutesLifetime': 0}) + @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) + + @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'}) + + @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/tests/latest/test_quantum_workspace.py b/src/quantum/azext_quantum/tests/latest/test_quantum_workspace.py index 19509ab0638..1639369e169 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,14 +15,21 @@ 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, 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, +) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) @@ -124,7 +132,7 @@ def test_workspace_create_destroy(self): 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 results[0]["holds"] >= 0.0 # 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 @@ -557,10 +564,40 @@ 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') + with self.assertRaises(InvalidArgumentValueError): + _require_v2_workspace(None) + with self.assertRaises(InvalidArgumentValueError): _apply_target_quotas([Provider(provider_id='provider')], [{ 'providerId': 'other-provider', @@ -575,6 +612,237 @@ 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( + 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 + )] + )) + usage = SimpleNamespace( + target_id='provider.target', + usage=Usage({'standardMinutesLifetime': 25, 'highMinutesLifetime': 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_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_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') + cmd = SimpleNamespace(cli_ctx=object()) + + 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 + )]) + 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 + )] + )) + usage = SimpleNamespace( + target_id='provider.target', + usage=Usage({'standardMinutesLifetime': usage_value}) + ) + + 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 = [usage] + + 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( + target_id='provider.target', standard_minutes_lifetime=50, high_minutes_lifetime=21 + )]) + 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 + )] + )) + usage = SimpleNamespace( + target_id='provider.target', + usage=Usage({'standardMinutesLifetime': 50, 'highMinutesLifetime': 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_workspace_usages.return_value = [usage] + + 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) + + 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=[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_reject_missing_suite_target(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"(?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) + 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( + 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), + 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_workspace_usages.return_value = [] + + _validate_target_quota_bounds(cmd, info, workspace, quota, include_usage=True) + + 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( + target_id='provider.target', standard_minutes_lifetime=0 + )]) + 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 + )] + )) + 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.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): @@ -600,24 +868,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) @@ -653,6 +959,212 @@ 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_workspace_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), + ]), + ])) + 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=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=[ + 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), 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=[ + 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=Usage({'standardMinutesLifetime': 9, 'highMinutesLifetime': 4})), + ] + + rows = _merge_workspace_quotas(workspace, usages) + + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]['providerId'], 'ionq') + 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=Usage({'standardMinutesLifetime': 9})), + ] + + 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) + legacy_quotas = [{'dimension': 'legacy'}] + self.assertEqual(_merge_workspace_quotas(workspace, [], legacy_quotas), legacy_quotas) + + def test_transform_workspace_quotas_preserves_mixed_dimensions(self): + quotas = [ + { + '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(list(table[0].keys()), [ + 'Scope', 'Provider ID', 'Target', 'Dimension', 'Limit', 'Utilization', 'Holds', 'Period' + ]) + 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_v2_usages_without_legacy_quotas(self): + info = SimpleNamespace(subscription='sub', resource_group='rg', name='ws', endpoint=None) + 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', + usage=Usage({'standardMinutesLifetime': 5, 'highMinutesLifetime': 2}))], + 'pasqal': [], + } + queried = [] + + def fake_list_workspace_usages(*args, **kwargs): + provider_id = kwargs['provider_id'] + queried.append(provider_id) + return usage_by_provider[provider_id] + + v2_client = SimpleNamespace(list_workspace_usages=fake_list_workspace_usages) + + 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, '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'}) + 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', 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' + } + 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, 'cf_quotas', return_value=legacy_client) as client_factory: + rows = workspace_ops.quotas(SimpleNamespace(cli_ctx=cli_ctx), 'rg', 'ws') + + self.assertEqual(rows, [legacy_row]) + client_factory.assert_called_once_with(cli_ctx, 'sub', 'rg', 'ws', endpoint) + + 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/_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 64077acdf86..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 @@ -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,22 +43,24 @@ build_services_jobs_update_request, build_services_providers_list_request, build_services_quotas_list_request, + build_services_quotas_list_workspace_usages_request, build_services_sessions_close_request, build_services_sessions_get_request, build_services_sessions_jobs_list_request, build_services_sessions_listv2_request, build_services_sessions_open_request, build_services_storage_get_sas_uri_request, + build_services_suite_offers_get_provider_status_request, + build_services_suite_offers_list_quota_usages_request, build_services_top_level_items_listv2_request, ) from .._configuration import WorkspaceClientConfiguration 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. @@ -83,9 +85,12 @@ def __init__(self, *args, **kwargs) -> None: 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 + ) -class ServicesTopLevelItemsOperations: +class ServicesTopLevelItemsOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -198,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( @@ -211,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) @@ -234,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. @@ -290,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 @@ -306,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 @@ -354,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. @@ -367,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: @@ -411,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 @@ -428,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()) @@ -448,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. @@ -464,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: """ @@ -476,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. @@ -492,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: """ @@ -512,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. @@ -528,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: """ @@ -555,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. @@ -568,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 = { @@ -587,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 @@ -612,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 @@ -629,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 @@ -756,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 @@ -773,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()) @@ -827,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 @@ -844,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()) @@ -942,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( @@ -955,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) @@ -978,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. @@ -1053,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( @@ -1066,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) @@ -1089,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. @@ -1164,7 +1193,126 @@ 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( + "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.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) + + 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 + @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.QuotaUsage"]: + """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 + :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 + :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 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.QuotaUsage]] = 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_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) + + 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( @@ -1177,7 +1325,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.QuotaUsage], + deserialized.get("value", []), + ) if cls: list_of_elem = cls(list_of_elem) # type: ignore return deserialized.get("nextLink") or None, AsyncList(list_of_elem) @@ -1200,7 +1351,7 @@ async def get_next(next_link=None): return AsyncItemPaged(get_next, extract_data) -class ServicesSessionsOperations: +class ServicesSessionsOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -1256,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 @@ -1272,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 @@ -1320,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. @@ -1333,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: @@ -1377,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 @@ -1394,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()) @@ -1448,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 @@ -1465,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()) @@ -1519,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 @@ -1536,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()) @@ -1641,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( @@ -1654,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) @@ -1769,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( @@ -1782,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) @@ -1805,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. @@ -1860,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 @@ -1877,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 @@ -1924,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 @@ -1938,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: @@ -1981,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 @@ -1998,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()) @@ -2006,3 +2175,196 @@ 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.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 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.QuotaUsage]] = 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() + values = deserialized if isinstance(deserialized, list) else deserialized.get("value", []) + list_of_elem = _deserialize( + list[_models.QuotaUsage], + values, + ) + if cls: + list_of_elem = cls(list_of_elem) # type: ignore + 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) + + _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 219a54d2c34..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 @@ -20,9 +20,11 @@ ItemDetails, JobDetails, JobUpdateOptions, + JobUpdateResponse, ProviderStatus, QuantumComputingData, Quota, + QuotaUsage, SasUriResponse, SessionDetails, TargetStatus, @@ -42,6 +44,7 @@ ProviderAvailability, SessionJobFailurePolicy, SessionStatus, + SuiteOfferScope, TargetAvailability, ) from ._patch import __all__ as _patch_all @@ -55,9 +58,11 @@ "ItemDetails", "JobDetails", "JobUpdateOptions", + "JobUpdateResponse", "ProviderStatus", "QuantumComputingData", "Quota", + "QuotaUsage", "SasUriResponse", "SessionDetails", "TargetStatus", @@ -74,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 b1ae5481cd0..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 @@ -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,6 +571,39 @@ class Quota(_Model): 'None' is used for concurrent quotas. Required. Known values are: \"None\" and \"Monthly\".""" +class QuotaUsage(_Model): + """Quota usage for a suite offer provider. + + :ivar provider_id: The unique identifier for the provider. Required. + :vartype provider_id: str + :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 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. + :vartype metadata: dict[str, str] + """ + + provider_id: str = rest_field(name="providerId", visibility=["read"]) + """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 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.""" + + class SasUriResponse(_Model): """SAS URI operation response. @@ -543,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 @@ -643,6 +718,12 @@ 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. 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 :ivar num_qubits: The qubit number. @@ -662,6 +743,16 @@ 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. 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. 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"]) @@ -680,7 +771,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. @@ -735,7 +826,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 4f6b49cc250..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 @@ -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,6 +331,35 @@ def build_services_quotas_list_request( return HttpRequest(method="GET", url=_url, params=_params, headers=_headers, **kwargs) +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 {}) + + 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["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") + + 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: @@ -543,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. @@ -568,9 +650,12 @@ def __init__(self, *args, **kwargs) -> None: 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 + ) -class ServicesTopLevelItemsOperations: +class ServicesTopLevelItemsOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -683,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( @@ -696,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) @@ -719,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. @@ -775,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 @@ -791,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 @@ -839,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. @@ -852,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: @@ -896,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 @@ -913,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()) @@ -933,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. @@ -949,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: """ @@ -961,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. @@ -977,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: """ @@ -997,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. @@ -1013,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: """ @@ -1040,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. @@ -1053,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 = { @@ -1072,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 @@ -1097,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 @@ -1114,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 @@ -1241,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 @@ -1258,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()) @@ -1312,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 @@ -1329,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()) @@ -1427,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( @@ -1440,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) @@ -1463,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. @@ -1538,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( @@ -1551,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) @@ -1574,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. @@ -1649,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( @@ -1662,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) @@ -1684,8 +1799,124 @@ def get_next(next_link=None): 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", + "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.QuotaUsage"]: + """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 + :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 + :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 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.QuotaUsage]] = kwargs.pop("cls", None) -class ServicesSessionsOperations: + 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_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) + + 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.QuotaUsage], + 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) + + +class ServicesSessionsOperations: # pylint: disable=docstring-missing-param """ .. warning:: **DO NOT** instantiate this class directly. @@ -1741,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 @@ -1757,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 @@ -1805,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. @@ -1818,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: @@ -1862,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 @@ -1879,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()) @@ -1933,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 @@ -1950,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()) @@ -2004,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 @@ -2021,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()) @@ -2126,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( @@ -2139,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) @@ -2254,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( @@ -2267,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) @@ -2290,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. @@ -2345,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 @@ -2362,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 @@ -2409,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 @@ -2423,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: @@ -2466,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 @@ -2483,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()) @@ -2491,3 +2740,194 @@ 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.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 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.QuotaUsage]] = 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() + values = deserialized if isinstance(deserialized, list) else deserialized.get("value", []) + list_of_elem = _deserialize( + list[_models.QuotaUsage], + values, + ) + if cls: + list_of_elem = cls(list_of_elem) # type: ignore + 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) + + _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 d241880cff5..e7be70b7fba 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.0b27' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers @@ -49,5 +49,7 @@ 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'], + }, )